Files
meshcore-analyzer/tests/e2e/test-pr-1490-live-map-gpu-animations-e2e.js
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

133 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
/* PR #1490 — the live map's canvas animation engine.
*
* The engine draws in-flight packets on its own canvas, drains the queue
* when each animation finishes, then goes back to sleep. Three things can
* regress without any visible error: the queue never drains (the map keeps
* repainting forever), the engine never sleeps (a permanent rAF loop), or
* the fading trails pile up past the cap of 5.
*
* Also pinned here (#1514 M2): the animation canvas and the fading polylines
* live on animationsPane (z=625), not on Leaflet's default overlayPane
* (z=400), where they would be drawn underneath the markers.
*
* Ported from the @playwright/test version of this file, which was written
* against a runner this project does not install and so had never run
* (#2037). The polling the original expressed with expect.poll is done here
* with waitForFunction. CHROMIUM_REQUIRE=1 makes Chromium-launch failure a
* HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// #1514 S5: 20, not 5, so the `while (recentPaths.length > 5)` prune at
// public/live.js:4285 actually runs and the cap assertion means something.
const PACKET_COUNT = 20;
const RECENT_PATHS_CAP = 5;
// One animation steps at scaledDt/660 (live.js:4154), so 660ms at 1x. The
// @playwright/test original allowed 1500ms, but that figure was never tested:
// the file had no runner. Measured against a populated instance, 20 queued
// animations do not all finish inside it. What this asserts is that the queue
// drains at all and the engine then sleeps, not that it does so within twice
// one animation's duration, so the budget is generous on purpose.
const DRAIN_TIMEOUT_MS = 8000;
let passes = 0, failures = 0;
function pass(msg) { console.log(` ✓ ${msg}`); passes++; }
function fail(msg) { console.error(` ✗ ${msg}`); failures++; }
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({ headless: true });
} catch (err) {
if (requireChromium) {
console.error(`HARD FAIL — Chromium unavailable: ${err.message}`);
process.exit(1);
}
console.warn(`SKIP — Chromium unavailable: ${err.message}`);
process.exit(0);
}
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('#liveMap', { timeout: 15000 });
await page.waitForFunction(
() => !!window._liveDrawAnimatedLine && !!window._liveTestSeams,
null,
{ timeout: 15000 },
);
} catch {
fail('the live map never exposed its animation seams within 15s');
await browser.close();
console.log(`\ntest-pr-1490-live-map-gpu-animations-e2e: ${passes} passed, ${failures} failed`);
process.exit(1);
}
// (1) The animation canvas is on animationsPane, not on overlayPane.
// Leaflet's own preferCanvas:true renderer also creates a canvas, on
// overlayPane, so an unqualified `canvas` selector would match the wrong one.
const canvasOnPane = await page.evaluate(() => {
const pane = document.querySelector('.leaflet-pane.leaflet-animations-pane');
if (!pane) return { pane: false, canvases: 0 };
return { pane: true, canvases: pane.querySelectorAll('canvas').length };
});
if (canvasOnPane.pane && canvasOnPane.canvases >= 1) {
pass('(1) the animation canvas is attached to animationsPane');
} else {
fail(`(1) animationsPane ${canvasOnPane.pane ? 'has no canvas' : 'is missing'} (canvases: ${canvasOnPane.canvases})`);
}
// (2) Firing packets queues exactly that many animations and wakes the engine.
const afterFire = await page.evaluate((count) => {
if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE');
for (let i = 0; i < count; i++) {
window._liveDrawAnimatedLine([37.4, -122.0], [37.5, -122.1], '#00ff00', null, null, '00AA', 'test-hash-' + i);
}
return { count: window._liveTestSeams.getAnimCount(), animating: window._liveTestSeams.isAnimating() };
}, PACKET_COUNT);
if (afterFire.count === PACKET_COUNT) pass(`(2) all ${PACKET_COUNT} animations were queued`);
else fail(`(2) queued ${afterFire.count} animations, expected ${PACKET_COUNT}`);
if (afterFire.animating) pass('(3) the engine woke up');
else fail('(3) the engine did not wake up, so nothing would be drawn');
// (4) The queue drains.
try {
await page.waitForFunction(() => window._liveTestSeams.getAnimCount() === 0, null, { timeout: DRAIN_TIMEOUT_MS, polling: 50 });
pass(`(4) the queue drained to 0 within ${DRAIN_TIMEOUT_MS}ms`);
} catch {
const left = await page.evaluate(() => window._liveTestSeams.getAnimCount());
fail(`(4) ${left} animation(s) still queued after ${DRAIN_TIMEOUT_MS}ms`);
}
// (5) The engine goes back to sleep. There is one rAF tick between the
// queue emptying and renderAnimations flipping isAnimating to false, so
// poll rather than read once (#1514).
try {
await page.waitForFunction(() => window._liveTestSeams.isAnimating() === false, null, { timeout: 1000, polling: 50 });
pass('(5) the engine went back to sleep');
} catch {
fail('(5) isAnimating stayed true with an empty queue, so the rAF loop never stops');
}
// (6) The fading trails respect the cap.
const paths = await page.evaluate(() => window._liveTestSeams.getPathCount());
if (paths <= RECENT_PATHS_CAP) pass(`(6) recentPaths held at ${paths}, within the cap of ${RECENT_PATHS_CAP}`);
else fail(`(6) recentPaths grew to ${paths}, past the cap of ${RECENT_PATHS_CAP}`);
await browser.close();
console.log(`\ntest-pr-1490-live-map-gpu-animations-e2e: ${passes} passed, ${failures} failed`);
process.exit(failures ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });