Files
meshcore-analyzer/test-issue-1640-compare-discovery-e2e.js
T
Kpa-clawbotandclawbot 531bc8acb3 feat(#1640): promote observer comparison to first-class — 3 new entry points + multi-select (#1642)
## 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>
2026-06-10 18:43:24 +00:00

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); });