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>
This commit is contained in:
Kpa-clawbot
2026-06-10 11:43:24 -07:00
committed by GitHub
parent d72ab69f87
commit 531bc8acb3
5 changed files with 324 additions and 4 deletions
+1
View File
@@ -430,6 +430,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-ws-race-1498-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1487-byop-modal-layout-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1630-reach-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1640-compare-discovery-e2e.js 2>&1 | tee -a e2e-output.txt
# #1616: slide-over focus-restore flake-gate. Runs the slide-over
# E2E 20 consecutive times against the SAME backend instance so
+31 -1
View File
@@ -101,10 +101,11 @@ if (typeof window !== 'undefined') {
routeFilter = 'all';
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">' +
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">' +
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
'<h2 style="margin:0">\uD83D\uDD0D Observer Comparison</h2>' +
'</div>' +
'<nav data-role="compare-breadcrumbs" aria-label="Compare breadcrumbs" class="compare-breadcrumbs" style="margin:0 0 12px 0;font-size:0.9em;color:var(--text-muted)"></nav>' +
'<div id="compareControls" class="compare-controls"><div class="text-center text-muted" style="padding:20px">Loading observers\u2026</div></div>' +
'<div id="compareContent"></div>' +
'</div>';
@@ -196,6 +197,7 @@ if (typeof window !== 'undefined') {
selA = ddA.value || null;
selB = ddB.value || null;
btn.disabled = !selA || !selB || selA === selB;
renderBreadcrumbs();
}
ddA.addEventListener('change', updateBtn);
ddB.addEventListener('change', updateBtn);
@@ -203,6 +205,34 @@ if (typeof window !== 'undefined') {
updateBtn();
}
// #1640 — render breadcrumbs linking back to each observer's detail page.
// Hidden when neither observer is picked; otherwise:
// "Observers <A name> ⇆ <B name>"
// The "&lrm;" entities keep punctuation LTR in RTL contexts.
function renderBreadcrumbs() {
var el = document.querySelector('[data-role="compare-breadcrumbs"]');
if (!el) return;
function linkFor(id) {
if (!id) return null;
var match = null;
for (var i = 0; i < observers.length; i++) {
if (String(observers[i].id) === String(id)) { match = observers[i]; break; }
}
var label = match ? (match.name || match.id) : id;
return '<a href="#/observers/' + encodeURIComponent(id) + '">' + escapeHtml(label) + '</a>';
}
var parts = ['<a href="#/observers">Observers</a>'];
var aLink = linkFor(selA);
var bLink = linkFor(selB);
if (aLink || bLink) {
var pair = [];
if (aLink) pair.push(aLink);
if (bLink) pair.push(bLink);
parts.push(pair.join(' <span aria-hidden="true">\u21C6</span> '));
}
el.innerHTML = parts.join(' <span aria-hidden="true">\u203A</span> ');
}
function sinceISO(hours) {
return new Date(Date.now() - hours * 3600000).toISOString();
}
+56 -1
View File
@@ -86,7 +86,19 @@ window.ObserverDetailNaiveBanner = {
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
<div style="margin-left:auto;display:flex;gap:8px">
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label class="sr-only" for="obsCompareWithPicker">Compare with another observer</label>
<select id="obsCompareWithPicker" data-action="compare-with-picker"
aria-label="Compare with another observer"
title="Pick another observer to compare against"
class="time-range-select">
<option value="">Compare with…</option>
</select>
<button type="button" data-action="compare-with-go" class="btn-secondary" disabled aria-disabled="true"
title="Open side-by-side comparison"
style="display:inline-flex;align-items:center;gap:6px">
<span aria-hidden="true">🔍</span><span>Compare</span>
</button>
<select id="obsDaysSelect" class="time-range-select" aria-label="Time range">
<option value="1">24 Hours</option>
<option value="3">3 Days</option>
@@ -103,6 +115,25 @@ window.ObserverDetailNaiveBanner = {
loadDetail();
});
// #1640 — "Compare with…" picker. Fetches the observer list once,
// populates options excluding the current observer, enables the
// Compare button only when a target is selected.
populateCompareWithPicker(currentId);
var picker = document.getElementById('obsCompareWithPicker');
var goBtn = document.querySelector('[data-action="compare-with-go"]');
if (picker && goBtn) {
picker.addEventListener('change', function () {
var enabled = !!picker.value;
goBtn.disabled = !enabled;
goBtn.setAttribute('aria-disabled', enabled ? 'false' : 'true');
});
goBtn.addEventListener('click', function () {
if (!picker.value || !currentId) return;
location.hash = '#/compare?a=' + encodeURIComponent(currentId) +
'&b=' + encodeURIComponent(picker.value);
});
}
loadDetail();
}
@@ -440,5 +471,29 @@ window.ObserverDetailNaiveBanner = {
});
}
// #1640 — populate the "Compare with…" dropdown with all other observers.
// Uses the same /observers list endpoint the observers page already caches,
// so this should hit the in-memory cache in the common case.
async function populateCompareWithPicker(thisId) {
var picker = document.getElementById('obsCompareWithPicker');
if (!picker) return;
try {
var data = await api('/observers', { ttl: (window.CLIENT_TTL && window.CLIENT_TTL.observers) || 120000 });
var list = (data && data.observers ? data.observers : [])
.filter(function (o) { return String(o.id) !== String(thisId); })
.sort(function (a, b) { return (a.name || a.id).localeCompare(b.name || b.id); });
var opts = ['<option value="">Compare with\u2026</option>'];
for (var i = 0; i < list.length; i++) {
var o = list[i];
var label = (o.name || o.id) + (o.iata ? ' (' + o.iata + ')' : '');
opts.push('<option value="' + escapeHtml(o.id) + '">' + escapeHtml(label) + '</option>');
}
picker.innerHTML = opts.join('');
} catch (e) {
// Leave the placeholder option in place; user can still navigate via
// the observers page Compare button.
}
}
registerPage('observer-detail', { init, destroy });
})();
+65 -2
View File
@@ -123,7 +123,19 @@ window.ObserversSummary = (function () {
<div class="observers-page">
<div class="page-header">
<h2>Observer Status</h2>
<a href="#/compare" class="btn-icon" title="Compare observers" aria-label="Compare observers" style="text-decoration:none">🔍</a>
<button type="button" class="btn-secondary" data-action="compare-observers"
title="Compare two observers side-by-side"
aria-label="Compare observers"
style="display:inline-flex;align-items:center;gap:6px">
<span aria-hidden="true">🔍</span><span>Compare observers</span>
</button>
<button type="button" class="btn-secondary" data-action="compare-selected"
title="Select exactly two rows to compare"
aria-label="Compare selected observers"
aria-disabled="true" disabled
style="display:inline-flex;align-items:center;gap:6px">
<span aria-hidden="true">⚖️</span><span>Compare selected (<span data-role="compare-count">0</span>)</span>
</button>
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
</div>
<div id="obsRegionFilter" class="region-filter-container"></div>
@@ -136,6 +148,23 @@ window.ObserversSummary = (function () {
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (btn && btn.dataset.action === 'obs-refresh') loadObservers({ bust: true });
if (btn && btn.dataset.action === 'compare-observers') {
location.hash = '#/compare';
return;
}
if (btn && btn.dataset.action === 'compare-selected') {
var picked = collectSelectedIds();
if (picked.length === 2) {
location.hash = '#/compare?a=' + encodeURIComponent(picked[0]) + '&b=' + encodeURIComponent(picked[1]);
}
return;
}
// #1640 — per-row checkbox: toggle, update compare-selected button state.
var cb = e.target.closest('input[data-compare-select]');
if (cb) {
updateCompareSelectedState();
return;
}
var row = e.target.closest('tr[data-action="navigate"]');
if (row) {
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
@@ -296,6 +325,7 @@ window.ObserversSummary = (function () {
<thead><tr>
<th scope="col" data-priority="1" data-sort-key="status" data-type="numeric">Status</th><th scope="col" data-priority="1" data-sort-key="name">Name</th><th scope="col" data-priority="3" data-sort-key="region">Region</th><th scope="col" data-priority="2" data-sort-key="last_seen" data-type="numeric">Last Status</th><th scope="col" data-priority="2" data-sort-key="last_packet_at" data-type="numeric">Last Packet</th>
<th scope="col" data-priority="3" data-sort-key="packet_health" data-type="numeric">Packet Health</th><th scope="col" data-priority="4" data-sort-key="packet_count" data-type="numeric">Total Packets</th><th scope="col" data-priority="3" data-sort-key="packets_hour" data-type="numeric">Packets/Hour</th><th scope="col" data-priority="4" data-sort-key="clock_offset" data-type="numeric">Clock Offset</th><th scope="col" data-priority="4" data-sort-key="uptime" data-type="numeric">Uptime</th>
<th scope="col" data-priority="1" class="col-compare-select" style="width:32px"><span class="sr-only">Select for compare</span></th>
</tr></thead>
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
@@ -316,7 +346,7 @@ window.ObserversSummary = (function () {
const _healthRank = h.cls === 'health-green' ? 2 : (h.cls === 'health-yellow' ? 1 : 0);
const _packetCount = (o.packet_count != null) ? o.packet_count : '';
const _packetsHour = (o.packetsLastHour != null) ? o.packetsLastHour : '';
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" data-observer-id="${escapeHtml(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
<td data-value="${_healthRank}"><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
<td data-testid="obs-cell-name" data-value="${escapeHtml(String(o.name || o.id))}" class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' <span class="badge-listener" title="Firmware reported repeat:off — listener-only; excluded from path-hop disambiguator (issue #1290)">listener</span>' : (o.can_relay === true ? ' <span class="badge-repeater" title="Firmware reported repeat:on — eligible as a path hop">repeater</span>' : '')}</td>
<td data-value="${escapeHtml(o.iata || '')}">${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
@@ -332,6 +362,11 @@ window.ObserversSummary = (function () {
return renderSkewBadge(sev, sk.offsetSec) + ' <span class="text-muted" title="Computed from ' + sk.samples + ' multi-observer packets. Positive = observer ahead of consensus.">(' + sk.samples + ')</span>';
})()}</td>
<td data-value="${_uptimeMs}">${uptimeStr(o.first_seen)}</td>
<td class="col-compare-select" onclick="event.stopPropagation()" style="text-align:center">
<input type="checkbox" data-compare-select value="${escapeHtml(o.id)}"
aria-label="Select ${escapeHtml(o.name || o.id)} for comparison"
onclick="event.stopPropagation()" />
</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
@@ -365,6 +400,34 @@ window.ObserversSummary = (function () {
registerPage('observers', { init, destroy });
// #1640 — multi-select compare wiring.
function collectSelectedIds() {
var boxes = document.querySelectorAll('#obsTable tbody input[data-compare-select]:checked');
var ids = [];
for (var i = 0; i < boxes.length; i++) ids.push(boxes[i].value);
return ids;
}
function updateCompareSelectedState() {
var btn = document.querySelector('[data-action="compare-selected"]');
if (!btn) return;
var ids = collectSelectedIds();
var countEl = btn.querySelector('[data-role="compare-count"]');
if (countEl) countEl.textContent = String(ids.length);
var enabled = ids.length === 2;
btn.disabled = !enabled;
btn.setAttribute('aria-disabled', enabled ? 'false' : 'true');
btn.title = enabled
? 'Compare the two selected observers'
: 'Select exactly two observers to enable (currently ' + ids.length + ')';
}
// Wire change events globally so the state updates even when checkbox
// toggles by keyboard (space) rather than mouse click.
document.addEventListener('change', function (e) {
if (e.target && e.target.matches && e.target.matches('input[data-compare-select]')) {
updateCompareSelectedState();
}
});
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
// summary from the in-memory observer + a link to the full page.
function openObserverSlideOver(hashHref) {
+171
View File
@@ -0,0 +1,171 @@
/**
* 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); });