mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 06:22:47 +00:00
## Summary The observer-comparison page (`#/compare`) is a powerful side-by-side overlap tool but was reachable from exactly one place — an icon-only 🔍 button in the observers page header. Most operators never found it. This PR promotes it to an IA citizen with **three new entry points** plus breadcrumbs back from the compare page to each observer's detail page. Red commit: `f937d29658e25973786f88a9ddeaaa33768f269e` (test asserts all three new affordances are present + navigate correctly; would have caught the original undiscoverability). Green commit: `5ceb34b66d780a971d3a43de06a0744445bdbecf`. ## Design rationale Three orthogonal user paths reach the same goal: - **Operator who lands on `/observers`** sees a labeled button — no more icon-guessing — and a row-selection workflow for direct manipulation ("pick two, compare"). - **Operator who lands on a specific observer's page** sees an in-context "Compare with…" picker — the comparison is parameterised with the current observer, removing the cognitive jump back to the list. - **Operator who already has two observer IDs** can still hit `#/compare?a=…&b=…` directly — legacy deep-links regression-guarded by the E2E. Plus: every compare-page view now shows `Observers › <A> ⇆ <B>` breadcrumbs that link back to each observer's detail page, so users can navigate sideways instead of bouncing through the list. ## Entry points added | # | Surface | Affordance | File:line | |---|---|---|---| | A | `/observers` header | `<button>` labeled "🔍 Compare observers" | `public/observers.js:125-130` | | B | `/observers/<id>` header | "Compare with…" `<select>` + Compare button | `public/observer-detail.js:90-103`, `:128-145`, `:436-456` | | D | `/observers` table | Per-row checkbox column + "Compare selected (N)" button enabled at exactly 2 | `public/observers.js:131-137`, `:295-302`, `:148-167`, `:354-378` | | breadcrumbs | `/compare` page | `data-role="compare-breadcrumbs"` with linked anchors → both detail pages | `public/compare.js:108`, `:202-228` | The pre-existing 🔍 link was REMOVED and replaced by (A) — the issue explicitly called for the icon-only affordance to go away. ## Before — current state on staging - Observers page header has only a bare 🔍 icon — no text label, indistinguishable from a generic search affordance. - Observer-detail page has zero comparison affordances; the user has to back out, find the observers list, locate the icon, then re-select both observers from scratch. - Compare page has a single back-arrow to `/observers` but no breadcrumb links to either compared observer's detail page. ## After — each new entry point browser-verified locally Built `cmd/server`, ran against `test-fixtures/e2e-fixture.db` on `:13581`, drove via headless chromium. Each step taken from a clean reload, screenshot captured (attached separately to the requesting session): - (A) Observers page header now shows a clearly-labeled "🔍 Compare observers" button alongside a "⚖️ Compare selected (N)" button (disabled when count !== 2). - (D) Two rows checked → "Compare selected (2)" enables → click → navigates to `#/compare?a=…&b=…` with both selects pre-populated and breadcrumbs reading `Observers › Kennedy Repeater ⇆ GY889 Repeater`. - (B) Observer-detail header now hosts a "Compare with…" `<select>` populated with the 30 other observers + a Compare button (disabled until a target is picked) → pick + click → navigates with the current observer pre-set as A. - Legacy `#/compare?a=…&b=…` deep-link still pre-populates both selects unchanged (covered by the E2E regression guard). ## Test plan - New: `test-issue-1640-compare-discovery-e2e.js` — 9 assertions across all three entry points + breadcrumbs + legacy-deep-link regression guard. Wired into `.github/workflows/deploy.yml`. - Local browser-verified each new affordance end-to-end (screenshots above). - `node --check test-issue-1640-compare-discovery-e2e.js` ✅ - Preflight clean (all 11 gates ✅), see below. ## Preflight checklist ``` ── [GATE] PII ── ✅ pass ── [GATE] Branch scope ── ✅ pass (5 files: 1 workflow, 3 frontend, 1 E2E) ── [GATE] Red commit ── ✅ pass (f937d29 verified failing) ── [GATE] CSS-var defined ── ✅ pass ── [GATE] CSS self-fallback ── ✅ pass ── [GATE] LIKE-on-JSON ── ✅ pass ── [GATE] Sync migration ── ✅ pass ── [GATE] Async-migration gate ── ✅ pass ── [GATE] XSS sinks ── ✅ pass ── [WARN] img/SVG ratio ── ✅ pass ── [WARN] Themed <img> SVG ── ✅ pass ── [WARN] Fixture coverage ── ✅ pass ═══ Preflight clean. ═══ ``` ## Accessibility - (A) and "Compare selected" buttons carry both visible text AND `aria-label`; disabled state uses both `disabled` and `aria-disabled="true"`. - (B) picker has an `<label class="sr-only">` plus `aria-label` for screen readers. - (D) per-row checkbox has `aria-label="Select <observer name> for comparison"`. - Breadcrumbs use `<nav aria-label="Compare breadcrumbs">` with a meaningful `›` separator (aria-hidden). ## Out of scope - The compare engine itself (`public/compare.js` data flow) is untouched. - New comparison metrics (track #671). - Analytics-nav link suggested as option (C) in the issue — covered by (A) which is more visible at the same top-nav tier; happy to add later if needed. Fixes #1640 --------- Co-authored-by: clawbot <bot@openclaw>
172 lines
9.1 KiB
JavaScript
172 lines
9.1 KiB
JavaScript
/**
|
|
* E2E test (#1640): Observer comparison must be a first-class IA citizen.
|
|
*
|
|
* Asserts THREE new entry points to `#/compare`, beyond the pre-existing
|
|
* 🔍 button on the observers page header:
|
|
*
|
|
* (A) Observers page header — a labeled button reading "Compare observers"
|
|
* (text + icon, NOT a bare emoji).
|
|
* (B) Observer-detail page — a "Compare with…" affordance that opens
|
|
* #/compare?a=<this>&b=<picked> pre-populated.
|
|
* (D) Multi-select on observers table — checkbox-per-row, enabling a
|
|
* "Compare selected" button once exactly two observers are checked.
|
|
*
|
|
* Also asserts:
|
|
* - The compare page renders breadcrumb links back to BOTH observer
|
|
* detail pages.
|
|
* - The legacy deep-link `#/compare?a=...&b=...` continues to work.
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-issue-1640-compare-discovery-e2e.js
|
|
*/
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
let passed = 0, failed = 0;
|
|
async function step(name, fn) {
|
|
try { await fn(); passed++; console.log(' \u2705 ' + name); }
|
|
catch (e) { failed++; console.error(' \u274c ' + name + ': ' + e.message); }
|
|
}
|
|
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
|
|
|
async function pickTwoObserverIds(page) {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTable tbody tr', { timeout: 15000 });
|
|
const ids = await page.$$eval('#obsTable tbody tr[data-value]', rows =>
|
|
rows.slice(0, 2).map(r => decodeURIComponent(
|
|
(r.getAttribute('data-value') || '').replace('#/observers/', '')
|
|
))
|
|
);
|
|
assert(ids.length === 2, 'need at least 2 observers in fixture, got ' + ids.length);
|
|
return ids;
|
|
}
|
|
|
|
async function run() {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
|
});
|
|
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
page.on('pageerror', e => console.error(' pageerror:', e.message));
|
|
|
|
console.log('\nRunning #1640 compare-discovery E2E tests against ' + BASE + '\n');
|
|
|
|
const [idA, idB] = await pickTwoObserverIds(page);
|
|
|
|
// ── Entry point A: labeled "Compare observers" on observers page ──
|
|
await step('(A) Observers page header has labeled "Compare observers" button', async () => {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTable tbody tr', { timeout: 15000 });
|
|
const btn = await page.$('[data-action="compare-observers"]');
|
|
assert(btn, 'expected element with data-action="compare-observers" in observers page header');
|
|
const text = (await btn.textContent() || '').trim();
|
|
assert(/compare/i.test(text),
|
|
'compare button must have visible text mentioning "Compare", got "' + text + '"');
|
|
});
|
|
|
|
await step('(A) Clicking "Compare observers" navigates to #/compare', async () => {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-action="compare-observers"]', { timeout: 10000 });
|
|
await page.click('[data-action="compare-observers"]');
|
|
await page.waitForFunction(() => location.hash.startsWith('#/compare'), null, { timeout: 5000 });
|
|
assert(/^#\/compare/.test(await page.evaluate(() => location.hash)),
|
|
'expected hash to become #/compare');
|
|
});
|
|
|
|
// ── Entry point B: observer-detail "Compare with…" picker ──
|
|
await step('(B) Observer detail page exposes a "Compare with…" affordance', async () => {
|
|
await page.goto(BASE + '/#/observers/' + idA, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTitle', { timeout: 15000 });
|
|
// Give detail page a moment to finish rendering.
|
|
await page.waitForSelector('[data-action="compare-with-picker"]', { timeout: 10000 });
|
|
const picker = await page.$('[data-action="compare-with-picker"]');
|
|
assert(picker, 'expected [data-action="compare-with-picker"] (select) on observer-detail');
|
|
const options = await picker.$$('option');
|
|
assert(options.length >= 2, 'compare-with picker should be populated with other observers');
|
|
});
|
|
|
|
await step('(B) Picking another observer + Compare navigates to #/compare?a=<idA>&b=<other>', async () => {
|
|
await page.goto(BASE + '/#/observers/' + idA, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-action="compare-with-picker"]', { timeout: 10000 });
|
|
await page.selectOption('[data-action="compare-with-picker"]', idB);
|
|
await page.click('[data-action="compare-with-go"]');
|
|
await page.waitForFunction((idA) =>
|
|
location.hash.indexOf('#/compare') === 0 &&
|
|
location.hash.indexOf('a=' + idA) >= 0, idA, { timeout: 5000 });
|
|
const h = await page.evaluate(() => location.hash);
|
|
assert(h.indexOf('b=' + idB) >= 0, 'expected deep-link to carry b=<picked>, got: ' + h);
|
|
});
|
|
|
|
// ── Entry point D: multi-select on observers table ──
|
|
await step('(D) Observers table renders one checkbox per row + "Compare selected" button', async () => {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTable tbody tr', { timeout: 15000 });
|
|
const boxes = await page.$$('#obsTable tbody input[type="checkbox"][data-compare-select]');
|
|
assert(boxes.length >= 2,
|
|
'expected per-row checkboxes ([data-compare-select]); got ' + boxes.length);
|
|
const btn = await page.$('[data-action="compare-selected"]');
|
|
assert(btn, 'expected [data-action="compare-selected"] button');
|
|
const disabled = await btn.evaluate(el => el.disabled || el.getAttribute('aria-disabled') === 'true');
|
|
assert(disabled, '"Compare selected" must be disabled when 0 rows are selected');
|
|
});
|
|
|
|
await step('(D) Selecting exactly two rows enables "Compare selected" and navigates correctly', async () => {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTable tbody tr', { timeout: 15000 });
|
|
const boxes = await page.$$('#obsTable tbody input[type="checkbox"][data-compare-select]');
|
|
await boxes[0].check();
|
|
await boxes[1].check();
|
|
const btn = await page.$('[data-action="compare-selected"]');
|
|
const stillDisabled = await btn.evaluate(el => el.disabled || el.getAttribute('aria-disabled') === 'true');
|
|
assert(!stillDisabled, '"Compare selected" must be enabled when exactly 2 rows are checked');
|
|
await btn.click();
|
|
await page.waitForFunction(() => location.hash.indexOf('#/compare?') === 0, null, { timeout: 5000 });
|
|
const h = await page.evaluate(() => location.hash);
|
|
assert(/a=[^&]+&b=[^&]+/.test(h),
|
|
'expected hash to carry both ?a=&b= deep-link params, got: ' + h);
|
|
});
|
|
|
|
await step('(D) Selecting a third row disables "Compare selected" again', async () => {
|
|
await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#obsTable tbody tr', { timeout: 15000 });
|
|
const boxes = await page.$$('#obsTable tbody input[type="checkbox"][data-compare-select]');
|
|
if (boxes.length < 3) return; // fixture might only have 2; skip silently
|
|
await boxes[0].check();
|
|
await boxes[1].check();
|
|
await boxes[2].check();
|
|
const btn = await page.$('[data-action="compare-selected"]');
|
|
const disabled = await btn.evaluate(el => el.disabled || el.getAttribute('aria-disabled') === 'true');
|
|
assert(disabled, '"Compare selected" must re-disable when count !== 2');
|
|
});
|
|
|
|
// ── Compare page breadcrumbs to both observer detail pages ──
|
|
await step('Compare page renders breadcrumb links back to both observer detail pages', async () => {
|
|
await page.goto(BASE + '/#/compare?a=' + idA + '&b=' + idB, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.compare-page', { timeout: 15000 });
|
|
await page.waitForSelector('[data-role="compare-breadcrumbs"]', { timeout: 10000 });
|
|
const linkA = await page.$('[data-role="compare-breadcrumbs"] a[href="#/observers/' + idA + '"]');
|
|
const linkB = await page.$('[data-role="compare-breadcrumbs"] a[href="#/observers/' + idB + '"]');
|
|
assert(linkA, 'expected breadcrumb anchor → #/observers/<idA>');
|
|
assert(linkB, 'expected breadcrumb anchor → #/observers/<idB>');
|
|
});
|
|
|
|
// ── Legacy deep-link regression guard ──
|
|
await step('Legacy deep-link #/compare?a=...&b=... still pre-populates both selects', async () => {
|
|
await page.goto(BASE + '/#/compare?a=' + idA + '&b=' + idB, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#compareObsA', { timeout: 15000 });
|
|
const valA = await page.$eval('#compareObsA', el => el.value);
|
|
const valB = await page.$eval('#compareObsB', el => el.value);
|
|
assert(valA === idA, 'compareObsA should be pre-selected to a=, got ' + valA);
|
|
assert(valB === idB, 'compareObsB should be pre-selected to b=, got ' + valB);
|
|
});
|
|
|
|
await browser.close();
|
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
|
if (failed) process.exit(1);
|
|
}
|
|
|
|
run().catch(e => { console.error(e); process.exit(1); });
|