mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-29 05:41:41 +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>
This commit is contained in:
@@ -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
@@ -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 "‎" 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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user