Files
meshcore-analyzer/tests/e2e/test-touch-targets.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

239 lines
10 KiB
JavaScript

#!/usr/bin/env node
/* Issue #1060 / PR #1067 follow-up — touch targets behavior test.
*
* MAJOR-2 from pr-polish review: the previous version of this file
* grep'd CSS strings, which is tautological — it asserted that the
* source contained the literal characters that were just edited in.
* It would have passed even if the CSS was syntactically broken or
* if selectors didn't match any element on the real page.
*
* This rewrite loads public/style.css into a real Chromium page via
* Playwright with an iPhone-class touch emulation context, renders
* representative DOM samples for every selector we claim to harden,
* and reads getBoundingClientRect()/getComputedStyle() to assert the
* 48x48 minimum hit area. It also exercises the .sort-help tap-to-
* reveal flow (focus event must un-hide the .sort-help-tip) since
* MAJOR-1 is enforced both in markup (tabindex="0" in packets.js) and
* in CSS (:focus / :focus-within rule in the Touch Targets section).
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const { chromium, devices } = require('playwright');
const REPO = REPO_ROOT;
const CSS = fs.readFileSync(path.join(REPO, 'public/style.css'), 'utf8');
// Minimum hit area per selector. 48 is the house default (public/style.css:610
// states it for the whole group); MIN_OVERRIDES carries the documented
// exceptions, so a third selector quietly dropping to 44 still fails here.
const DEFAULT_MIN = 48;
const MIN_OVERRIDES = {
// Both are declared twice in public/style.css: 48px in the touch-target
// block (.nav-btn at :561, .ch-icon-btn at :567) and 44px in their own
// component rule further down (:890 and :1838), which wins. 44 is what
// ships, and it is the WCAG 2.5.5 / Apple HIG figure the later rule cites.
// Pinned at the effective value rather than the aspirational one; the
// contradiction itself is #2052, and is not a test problem. Remove these
// two entries when that is settled either way.
'.nav-btn': 44,
'.ch-icon-btn': 44,
};
// Selectors we claim to make DEFAULT_MIN square, except where MIN_OVERRIDES
// says otherwise. Each entry: [selector, tag, classes, optional inner-html].
// Tag matters because some rules are scoped to `button.ch-item` and some only
// apply to specific input[type=...].
//
// Not listed, and why:
// .compare-btn the Compare CTA was removed in #1646 (see style.css)
// .ch-back-btn display:none outside the mobile channels layout, so a
// standalone element in this harness measures 0x0
// .filter-toggle-btn hidden on mobile since #1461 (style.css:1161,
// display:none !important); the control actually shown is
// the navbar mirror, which mobile-page-actions.js:70
// builds with class "nav-btn filter-toggle-btn-mirror
// mpa-btn-pill", so the .nav-btn entry above already
// measures it
const BUTTON_SELECTORS = [
['.btn', 'button', 'btn'],
['.btn-icon', 'button', 'btn-icon'],
['.nav-btn', 'button', 'nav-btn'],
['.ch-icon-btn', 'button', 'ch-icon-btn'],
['.ch-remove-btn', 'button', 'ch-remove-btn'],
['.ch-share-btn', 'button', 'ch-share-btn'],
['.ch-gear-btn', 'button', 'ch-gear-btn'],
['.panel-close-btn', 'button', 'panel-close-btn'],
['.mc-jump-btn', 'button', 'mc-jump-btn'],
['button.ch-item', 'button', 'ch-item'],
['.btn-link', 'button', 'btn-link'],
['.col-toggle-btn', 'button', 'col-toggle-btn'],
['.ch-add-channel-btn', 'button', 'ch-add-channel-btn'],
['.ch-modal-btn-secondary','button', 'ch-modal-btn-secondary'],
['.ch-scroll-btn', 'button', 'ch-scroll-btn'],
['.chooser-btn', 'button', 'chooser-btn'],
['.clock-filter-btn', 'button', 'clock-filter-btn'],
['.copy-link-btn', 'button', 'copy-link-btn'],
['.alab-btn', 'button', 'alab-btn'],
];
// Form controls. min-WIDTH is not enforced on these (text fields legitimately
// span a wide column); we only require min-height: 48px.
const FIELD_SELECTORS = [
['select', 'select', '', '<option>x</option>'],
['input[type=text]', 'input', '', null, { type: 'text' }],
['input[type=search]', 'input', '', null, { type: 'search' }],
['input[type=number]', 'input', '', null, { type: 'number' }],
['input[type=email]', 'input', '', null, { type: 'email' }],
['input[type=password]', 'input', '', null, { type: 'password' }],
['input[type=tel]', 'input', '', null, { type: 'tel' }],
['input[type=url]', 'input', '', null, { type: 'url' }],
['input[type=date]', 'input', '', null, { type: 'date' }],
['input[type=time]', 'input', '', null, { type: 'time' }],
];
function buildSampleHtml() {
const buttons = BUTTON_SELECTORS
.map(([_, tag, cls]) => `<${tag} class="${cls}" data-sel="${cls}">x</${tag}>`)
.join('\n ');
const fields = FIELD_SELECTORS
.map(([sel, tag, cls, inner, attrs]) => {
const attrStr = attrs
? Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')
: '';
const open = `<${tag} ${attrStr} data-sel="${sel.replace(/[\[\]=]/g, '_')}">`;
const close = tag === 'input' ? '' : `${inner || ''}</${tag}>`;
return open + close;
})
.join('\n ');
// .sort-help sample mirrors the markup the JS produces (post-fix):
// tabindex="0" so :focus-within can fire on touch tap.
return `<!doctype html>
<html><head><meta charset="utf-8">
<style>${CSS}</style>
</head><body>
<div id="harness" style="padding: 16px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
${buttons}
${fields}
<span class="sort-help" id="sortHelp" tabindex="0" role="button" aria-label="Sort help">ⓘ
<span class="sort-help-tip">Tip body</span>
</span>
</div>
</body></html>`;
}
async function run() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
// Allow the test to be skipped on hosts where Chromium cannot launch
// (e.g. some musl-libc dev boxes). CI uses standard glibc Ubuntu runners
// where this path is never taken. Set TOUCH_TARGETS_REQUIRE=1 to force
// a hard failure even when Chromium is unavailable.
if (process.env.TOUCH_TARGETS_REQUIRE === '1') throw err;
console.log(`test-touch-targets.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
// iPhone 13 has hasTouch:true, isMobile:true, no hover. Exactly the
// capability matrix that the @media (hover: hover) gate and 48px
// minimums are designed for.
const iPhone = devices['iPhone 13'];
const context = await browser.newContext({ ...iPhone });
const page = await context.newPage();
// Load the harness via a data: URL so we don't need a running server.
const html = buildSampleHtml();
await page.setContent(html, { waitUntil: 'load' });
if (page.evaluate) {
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
}
let failures = 0;
function record(name, ok, detail) {
if (ok) {
console.log(` \u2705 ${name}`);
} else {
console.log(` \u274c ${name}: ${detail}`);
failures++;
}
}
// --- Buttons: rendered hit area must be at least 48x48 CSS px.
for (const [selector, , cls] of BUTTON_SELECTORS) {
const dim = await page.$eval(`[data-sel="${cls}"]`, (el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { w: r.width, h: r.height, mh: cs.minHeight, mw: cs.minWidth };
});
const min = MIN_OVERRIDES[selector] || DEFAULT_MIN;
const okH = dim.h >= min;
const okW = dim.w >= min;
record(`${selector}: rendered ${dim.w.toFixed(1)}x${dim.h.toFixed(1)} (min ${dim.mw}/${dim.mh}, required ${min})`,
okH && okW,
`expected >=${min}x${min}, got ${dim.w}x${dim.h}`);
}
// --- Form controls: rendered height must be at least 48 CSS px.
for (const [selector, , , , attrs] of FIELD_SELECTORS) {
const dataKey = selector.replace(/[\[\]=]/g, '_');
const dim = await page.$eval(`[data-sel="${dataKey}"]`, (el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { h: r.height, mh: cs.minHeight };
});
record(`${selector}: rendered height ${dim.h.toFixed(1)} (min ${dim.mh})`,
dim.h >= 48,
`expected height >=48, got ${dim.h}`);
}
// --- MAJOR-1 verification: .sort-help is keyboard/tap focusable AND the
// tooltip becomes visible on focus (tap-to-reveal works without hover).
const tabIndex = await page.$eval('#sortHelp', (el) => el.getAttribute('tabindex'));
record('.sort-help has tabindex="0" in markup', tabIndex === '0',
`expected "0", got ${JSON.stringify(tabIndex)}`);
const tipBeforeFocus = await page.$eval('#sortHelp .sort-help-tip',
(el) => getComputedStyle(el).display);
// CSS rule on touch-only viewport: hover-rule is gated, focus-rule reveals.
record('.sort-help-tip is hidden by default on touch', tipBeforeFocus === 'none',
`expected display:none initially, got ${tipBeforeFocus}`);
await page.focus('#sortHelp');
const tipAfterFocus = await page.$eval('#sortHelp .sort-help-tip',
(el) => getComputedStyle(el).display);
record('.sort-help-tip becomes visible on focus (tap-to-reveal)',
tipAfterFocus === 'block',
`expected display:block after focus, got ${tipAfterFocus}`);
// --- Hover-only rule must be gated behind @media (hover: hover) so that on
// touch the iPhone context never enters a "stuck hover" state when a tap
// toggles :hover. We assert this by reading the matchMedia value the page
// sees and confirming :hover did NOT take effect on tap.
const hoverCapable = await page.evaluate(() => matchMedia('(hover: hover)').matches);
record('iPhone context reports (hover: hover) = false', hoverCapable === false,
`expected false on touch device, got ${hoverCapable}`);
await browser.close();
if (failures > 0) {
console.log(`\ntest-touch-targets.js: FAIL (${failures} assertion(s))`);
process.exit(1);
}
console.log('\ntest-touch-targets.js: OK');
}
run().catch((err) => {
console.error('test-touch-targets.js: fatal', err);
process.exit(1);
});