mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 03:57:27 +00:00
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>
239 lines
10 KiB
JavaScript
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);
|
|
});
|