Files
meshcore-analyzer/tests/e2e/test-table-sort.js
T
efitenandClaude Opus 5 25f8426d32 test: make every suite in tests/e2e run, and tell the truth when it does (#2053)
Closes #2037.

Step 1 (#2045) wired in the nine suites that already passed. This is
steps 2 and 3: the four that ran and failed, and the five that could not
run at all. After this, the count of suites in `tests/e2e` invoked by
nothing goes from 18 to 0.

## Step 2 — the four that ran and failed

Triaged against a fixture server in CI with full output kept, not the
four-line tail the first probe saved.

| suite | verdict |
|---|---|
| `test-packets-scope-column.js` | passes on master today. It failed on
2026-09-18, so something between the two fixed it. Wired in unchanged
rather than investigated. |
| `test-node-reach-e2e.js` | test wrong. It waited for the reach map
whenever any *link* had GPS; `public/node-reach.js` builds the map only
when the *node* has coordinates. Guaranteed 10s timeout on a node with
positioned neighbours and no position of its own. |
| `test-channel-modal-e2e.js` | both failures test-side. The Add
button's visible label was shortened to "+ Add" with the accessible name
moved to `aria-label` (`public/channels.js:746`); and
`.ch-section-mychannels` is conditional on the visitor having added a
channel, which has not happened at that point in the suite. |
| `test-touch-targets.js` | three test-side, two a product finding. |

The three test-side touch-target failures were the harness measuring
controls that are not shown: `.compare-btn` (the CTA was removed in
#1646, and `style.css` says so), `.ch-back-btn` (`display:none` outside
the mobile channels layout), and `.filter-toggle-btn` (`display:none` on
mobile since #1461; the control shown is the navbar mirror, which
`mobile-page-actions.js:70` builds as a `.nav-btn`, so it was already
measured). All three are dropped from the table with the reason recorded
in the file.

The remaining two are **not** a test problem: `.nav-btn` and
`.ch-icon-btn` are each declared twice in `public/style.css`, 48px in
the touch-target block and 44px in their own component rule, and the
later one wins. Rather than lower the blanket or hide the failures, the
suite now has `DEFAULT_MIN = 48` plus a `MIN_OVERRIDES` table holding
those two at their effective 44, so a third selector dropping to 44
still fails the build. The contradiction is **#2052**, with both ways
out costed; the override entries should go when it is settled.

## Step 3 — the five that could not run

None is deleted. I checked each selector and seam against the product
before deciding, and every one still targets a surface that exists and
that nothing else covers.

Four were written against `@playwright/test`, a runner the project
neither installs nor uses anywhere else. Adopting a second runner for
twelve tests costs more than porting them, and the precedent is already
set: `test-path-inspector-coverage-e2e.js` exists, as its own header
says, because `test-path-inspector-e2e.js` could not run. So they are
ported to the plain-node Chromium pattern the other 109 suites use.

- **`test-issue-1522-trace-url-sync-e2e.js`** — the trace hash in the
URL, both directions. `test-e2e-playwright.js` covers that the page
loads and searches; it never looks at the URL, which is the whole of
#1522.
- **`test-marker-outline-weight.js`** — the canvas pulse ring never
thins below 2px. There is no CSS rule to read and axe cannot see inside
a canvas, so sampling the seam is the only way. Added a guard that the
ring was actually visible, so the weight check cannot pass vacuously on
a pulse that never rendered.
- **`test-pr-1490-live-map-gpu-animations-e2e.js`** — the queue drains,
the engine sleeps again, the fading trails stay under the cap of 5, and
the canvas sits on `animationsPane` rather than under the markers.
- **`test-path-inspector-e2e.js`** — reduced to what nothing else
covers: the map side pane, the `/#/traces/<hash>` redirect, the tools
landing. Its standalone-page test duplicated the wired coverage suite
and is dropped. Its "switching candidate clears prior polyline" case
ended after the click with a comment and no assertion, which is the same
green-but-empty problem this issue is about; it now compares path
counts, and skips loudly when the fixture yields too few candidates.

The fifth, **`test-table-sort.js`**, needed `jsdom`, which was declared
nowhere. It is a unit test of `public/table-sort.js` filed under
`tests/e2e`, so: `jsdom` is a devDependency (lockfile updated, `npm ci`
stays consistent), the file moved to `tests/unit/`, and the
`domIntegration` group in `scripts/non-unit-tests.json` is gone with its
only member. It runs 22 tests. 20 passed immediately; 2 had rotted,
because #1648 M2 replaced the up/down glyphs with Phosphor sprites and
the direction moved out of `textContent` into the `<use href>`. Those
two now read the sprite ref and the `aria-sort` value, so they also
guard the accessible announcement.

## Verification

`tests/unit/test-table-sort.js` 22/22 and `test-test-inventory.js` pass
locally; the E2E suites need a fixture server, which I cannot build here
(no cgo toolchain since #1992), so CI is their first run as committed.
The triage above was measured in CI, not assumed.

## Not done

The per-assertion skips named in the second comment on #2037 are
untouched: the two flaky packet-detail cases, the fixture-data ones, and
the two `clientRxCoverage` suites that skip wholesale while reporting
success. Those need a fixture deployment with coverage enabled, which is
its own change. I have not opened it.

Option 2 from the issue, making `test-test-inventory.js` require a
`deploy.yml` line for every `tests/e2e` file, is also not here. It is
the right guard and it is now enforceable, since the list is finally at
zero, but it belongs in its own change where a red build means what it
says.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:45:11 +02:00

374 lines
13 KiB
JavaScript

/* test-table-sort.js — Unit tests for TableSort utility */
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✓ ${name}`);
} catch (e) {
failed++;
console.log(` ✗ ${name}`);
console.log(` ${e.message}`);
}
}
function createDOM(html) {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></html>`, {
url: 'http://localhost',
runScripts: 'dangerously'
});
// Load TableSort into this DOM
const script = fs.readFileSync(path.join(REPO_ROOT, 'public', 'table-sort.js'), 'utf8');
const el = dom.window.document.createElement('script');
el.textContent = script;
dom.window.document.head.appendChild(el);
return dom;
}
function makeTable(headers, rows) {
// headers: [{key, type?, label}], rows: [[value, ...]]
let html = '<table id="t"><thead><tr>';
for (const h of headers) {
html += `<th data-sort-key="${h.key}"${h.type ? ` data-type="${h.type}"` : ''}>${h.label || h.key}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of rows) {
html += '<tr>';
for (let i = 0; i < row.length; i++) {
const val = row[i];
if (typeof val === 'object' && val !== null) {
html += `<td data-value="${val.dataValue}">${val.text || ''}</td>`;
} else {
html += `<td data-value="${val}">${val}</td>`;
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function getColumnValues(dom, colIndex) {
const rows = dom.window.document.querySelectorAll('tbody tr');
return Array.from(rows).map(r => r.cells[colIndex].getAttribute('data-value'));
}
console.log('\nTableSort — comparators');
test('text comparator: basic alphabetical', () => {
const cmp = (() => {
const dom = createDOM('<div></div>');
return dom.window.TableSort.comparators.text;
})();
assert.ok(cmp('apple', 'banana') < 0);
assert.ok(cmp('banana', 'apple') > 0);
assert.strictEqual(cmp('same', 'same'), 0);
});
test('text comparator: null/undefined handling', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.text;
assert.strictEqual(cmp(null, null), 0);
assert.strictEqual(cmp(undefined, undefined), 0);
});
test('numeric comparator: basic numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('1', '2') < 0);
assert.ok(cmp('10', '2') > 0);
assert.strictEqual(cmp('5', '5'), 0);
});
test('numeric comparator: NaN sorts last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('abc', '5') > 0); // NaN > number (sorts last)
assert.ok(cmp('5', 'abc') < 0);
assert.strictEqual(cmp('abc', 'xyz'), 0); // both NaN
});
test('numeric comparator: negative numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('-10', '-5') < 0);
assert.ok(cmp('-5', '-10') > 0);
});
test('date comparator: ISO dates', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('2024-01-01T00:00:00Z', '2024-06-01T00:00:00Z') < 0);
assert.ok(cmp('2024-06-01T00:00:00Z', '2024-01-01T00:00:00Z') > 0);
assert.strictEqual(cmp('2024-01-01', '2024-01-01'), 0);
});
test('date comparator: invalid dates sort last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('invalid', '2024-01-01') > 0);
assert.ok(cmp('2024-01-01', 'invalid') < 0);
});
test('dBm comparator: strips suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120 dBm', '-80 dBm') < 0);
assert.ok(cmp('-80 dBm', '-120 dBm') > 0);
assert.strictEqual(cmp('-95 dBm', '-95 dBm'), 0);
});
test('dBm comparator: works without suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120', '-80') < 0);
});
console.log('\nTableSort — DOM sorting');
test('sort ascending by text column', () => {
const html = makeTable(
[{key: 'name'}],
[['Charlie'], ['Alice'], ['Bob']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['Alice', 'Bob', 'Charlie']);
});
test('sort descending by numeric column', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'val', defaultDirection: 'desc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']);
});
test('click toggles direction', () => {
const html = makeTable(
[{key: 'name'}],
[['B'], ['A'], ['C']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
// Initially ascending
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
// Click same header → descending
const th = dom.window.document.querySelector('th[data-sort-key="name"]');
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['C', 'B', 'A']);
// Click again → ascending
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
});
console.log('\nTableSort — aria-sort attributes');
test('aria-sort set correctly on active column', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'ascending');
assert.strictEqual(thB.getAttribute('aria-sort'), 'none');
});
test('aria-sort updates on direction change', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), 'ascending');
th.click(); // toggle to desc
assert.strictEqual(th.getAttribute('aria-sort'), 'descending');
});
test('aria-sort updates when switching columns', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
thB.click(); // switch to column b
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'none');
assert.strictEqual(thB.getAttribute('aria-sort'), 'ascending');
});
console.log('\nTableSort — visual indicator');
// The sort indicator is a Phosphor sprite since #1648 M2: the direction is
// carried by the <use href="...#ph-caret-up|down">, not by a text glyph.
// Reading it through the href keeps this test honest if the icon set changes
// again, because it then fails rather than passing on an empty textContent.
function sortArrowDirection(arrow) {
const use = arrow && arrow.querySelector('use');
const href = use && (use.getAttribute('href') || use.getAttribute('xlink:href'));
const m = href && href.match(/#ph-caret-(up|down)$/);
return m ? m[1] : null;
}
test('sort arrow shows on active column', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.ok(arrow, 'sort arrow should exist');
// #1648 M2 replaced the up/down glyphs with Phosphor sprite refs, so the
// direction now lives in the <use href>, not in textContent.
assert.strictEqual(sortArrowDirection(arrow), 'up', 'ascending should point the caret up');
const activeTh = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(activeTh.getAttribute('aria-sort'), 'ascending', 'ascending must be announced');
});
test('sort arrow changes on direction toggle', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click(); // desc
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.strictEqual(sortArrowDirection(arrow), 'down', 'descending should point the caret down');
assert.strictEqual(th.getAttribute('aria-sort'), 'descending', 'descending must be announced');
});
console.log('\nTableSort — onSort callback');
test('onSort fires with column and direction', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
let called = null;
dom.window.TableSort.init(table, {
domReorder: false,
onSort: function(col, dir) { called = { col, dir }; }
});
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click();
assert.ok(called, 'onSort should fire');
assert.strictEqual(called.col, 'a');
assert.strictEqual(called.dir, 'asc');
});
console.log('\nTableSort — domReorder: false');
test('domReorder: false skips DOM sorting', () => {
const html = makeTable(
[{key: 'name'}],
[['C'], ['A'], ['B']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc', domReorder: false });
// DOM order should NOT change
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['C', 'A', 'B']);
});
console.log('\nTableSort — destroy');
test('destroy removes event handlers and cleans up', () => {
const html = makeTable(
[{key: 'a'}],
[['2'], ['1']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
inst.destroy();
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), null, 'aria-sort should be removed');
assert.ok(!th.classList.contains('sort-active'), 'sort-active should be removed');
assert.strictEqual(th.querySelector('.sort-arrow'), null, 'arrow should be removed');
});
console.log('\nTableSort — custom comparators');
test('custom comparator overrides built-in', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
// Custom: reverse numeric
dom.window.TableSort.init(table, {
defaultColumn: 'val', defaultDirection: 'asc',
comparators: { val: function(a, b) { return Number(b) - Number(a); } }
});
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']); // reversed
});
console.log('\nTableSort — date sort with data-type="date"');
test('date column sorts correctly', () => {
const html = makeTable(
[{key: 'ts', type: 'date'}],
[['2024-06-15T10:00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-12-25T23:59:59Z']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'ts', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['2024-01-01T00:00:00Z', '2024-06-15T10:00:00Z', '2024-12-25T23:59:59Z']);
});
// Summary
console.log(`\n${passed + failed} tests, ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);