From 12c3b42d6b66f1086c553bd0df9a0471460dad94 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Fri, 31 Jul 2026 09:44:31 +0530 Subject: [PATCH 1/4] Alerts: complete run ids + humanized durations in alert copy (#491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stalled-run alert built its detail from run.runId.slice(-12), clipping every id longer than 12 chars into something unsearchable (run-fx-blocked → "n-fx-blocked", run-fx-active → "un-fx-active"), and printed raw minute math ("42431 min" for a 29-day stall). The cost alert had the same slice(-12) mangle. The studio3d freshness chip had the sibling problem — "Observed 42433m ago" with no hours/days tiers. Alert details now name the complete run id, and durations render through humanizeDurationMs ("45m", "1h 30m", "29d 11h"). formatSnapshotAge gains matching hours/days tiers ("Observed 29d 11h ago"); the two formatters are cross-referenced so the shapes stay in step. Closes #491. Co-Authored-By: Claude Fable 5 --- src/observability/alerts/engine.js | 16 +++- .../dashboard/ui/studio3d/model.js | 9 ++- tests/hub-alerts-copy-491.test.js | 75 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/hub-alerts-copy-491.test.js diff --git a/src/observability/alerts/engine.js b/src/observability/alerts/engine.js index a4d7238b..9f5b6478 100644 --- a/src/observability/alerts/engine.js +++ b/src/observability/alerts/engine.js @@ -9,6 +9,18 @@ export const DEFAULT_ALERT_THRESHOLDS = Object.freeze({ stalledRunMinutes: 30, // run with no events for this long }); +// #491: human-scale duration for alert copy — "45m", "1h 30m", "29d 11h", +// never an unbounded minute dump like "42431 min". The studio3d freshness +// chip (ui/studio3d/model.js formatSnapshotAge) mirrors this shape; keep the +// two in step if the format ever changes. +export function humanizeDurationMs(ms) { + const minutes = Math.floor(Math.max(0, Number(ms) || 0) / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + return `${Math.floor(hours / 24)}d ${hours % 24}h`; +} + export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { const alerts = []; const now = Date.now(); @@ -26,7 +38,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { level: cost >= thresholds.costPerRunUsd * 2 ? 'critical' : 'warn', type: 'cost_threshold', title: 'Run cost threshold exceeded', - detail: `Run ${run.runId.slice(-12)} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`, + detail: `Run ${run.runId} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`, runId: run.runId, ts: now, }); @@ -67,7 +79,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { level: 'warn', type: 'stalled_run', title: 'Run may be stalled', - detail: `No activity in ${run.runId.slice(-12)} for ${Math.round((now - lastTs) / 60000)} min`, + detail: `No activity in ${run.runId} for ${humanizeDurationMs(now - lastTs)}`, runId: run.runId, ts: now, }); diff --git a/src/observability/dashboard/ui/studio3d/model.js b/src/observability/dashboard/ui/studio3d/model.js index 5e350213..682cbc79 100644 --- a/src/observability/dashboard/ui/studio3d/model.js +++ b/src/observability/dashboard/ui/studio3d/model.js @@ -39,7 +39,14 @@ export function formatSnapshotAge(freshness) { if (!Number.isFinite(age)) return `Observed ${freshness.observed_at}`; if (age < 1_000) return 'Observed just now'; if (age < 60_000) return `Observed ${Math.floor(age / 1_000)}s ago`; - return `Observed ${Math.floor(age / 60_000)}m ago`; + // #491: hours and days tiers — a month-old snapshot must never render as + // an unbounded "42433m ago". Same shape as alerts/engine.js + // humanizeDurationMs; keep the two in step if the format ever changes. + const minutes = Math.floor(age / 60_000); + if (minutes < 60) return `Observed ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Observed ${hours}h ${minutes % 60}m ago`; + return `Observed ${Math.floor(hours / 24)}d ${hours % 24}h ago`; } export function entityRef(kind, id) { diff --git a/tests/hub-alerts-copy-491.test.js b/tests/hub-alerts-copy-491.test.js new file mode 100644 index 00000000..1d200022 --- /dev/null +++ b/tests/hub-alerts-copy-491.test.js @@ -0,0 +1,75 @@ +/** + * #491 — alert copy names the COMPLETE run id (never a slice(-12) mangle like + * "n-fx-blocked") and humanizes durations ("29d 11h", never "42431 min"). + * The studio3d freshness chip shares the same duration shape. + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { evaluateAlerts } from '../src/observability/alerts/engine.js'; +import { formatSnapshotAge } from '../src/observability/dashboard/ui/studio3d/model.js'; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +// A realistic run id — longer than 12 chars, so any slice(-12) mangle shows. +const LONG_RUN_ID = '2026-07-01T00-00-00-000Z-billing-portal'; + +function stalledState(runId, stalledForMs) { + return { + runs: [{ + runId, + manifest: { goal: 'g' }, + events: [{ ts: new Date(Date.now() - stalledForMs).toISOString(), type: 'task_started' }], + }], + }; +} + +test('stalled-run alert names the complete run id and humanizes a multi-day stall (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 29 * DAY + 11 * HOUR)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.ok(stalled.detail.includes(LONG_RUN_ID), + `detail names the complete run id — got: ${stalled.detail}`); + assert.doesNotMatch(stalled.detail, /\d{3,} ?min\b/, + 'no raw minute dumps like "42431 min"'); + assert.match(stalled.detail, /\b29d \d+h\b/, 'multi-day stalls read as days + hours'); +}); + +test('stalled-run alert humanizes an hours-scale stall (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 90 * MIN)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.match(stalled.detail, /\b1h 30m\b/, '90 minutes reads as 1h 30m'); +}); + +test('stalled-run alert keeps plain minutes below an hour (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 45 * MIN)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.match(stalled.detail, /\b45m\b/, 'sub-hour stalls read as minutes'); +}); + +test('cost alert names the complete run id — the second slice(-12) call site (#491)', () => { + const alerts = evaluateAlerts({ + runs: [{ runId: LONG_RUN_ID, manifest: {}, metrics: { cumulative_cost_usd: 9.99 }, events: [] }], + }); + const cost = alerts.find((alert) => alert.type === 'cost_threshold'); + assert.ok(cost, 'a cost alert fires'); + assert.ok(cost.detail.includes(LONG_RUN_ID), + `cost detail names the complete run id — got: ${cost.detail}`); +}); + +test('studio3d snapshot age humanizes hours and days, never unbounded minutes (#491)', () => { + const at = '2026-07-01T00:00:00.000Z'; + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 45 * 1000 }), 'Observed 45s ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 45 * MIN }), 'Observed 45m ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 90 * MIN }), 'Observed 1h 30m ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 29 * DAY + 11 * HOUR }), 'Observed 29d 11h ago'); + assert.doesNotMatch(formatSnapshotAge({ observed_at: at, age_ms: 42_433 * MIN }), /\d{3,}m ago/, + 'no unbounded minute chips like "42433m ago"'); +}); From 6870c6bfd7e79f132d102bcc5cd333f181da81ee Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Fri, 31 Jul 2026 09:47:01 +0530 Subject: [PATCH 2/4] Command Center: hero rationale never repeats the headline (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a focused run, the overview projection's title already IS the readiness summary (state/overview.js), and the page rendered the rationale as readiness.summary || title — so the hero painted the identical sentence twice ("Release is blocked by 6 source-backed conditions." as both headline and sub-line), adding zero information. The projection now owns a server-side `rationale` that is null whenever it would merely repeat the title (kept only when readiness.summary genuinely differs, e.g. the no-run state); the page consumes overview.rationale alone — the readiness.summary fallback that caused the echo is gone — and hides the element when there is nothing distinct to say. Honest empties preserved: no rationale is ever invented. Pinned by projection invariants across run/no-run/no-summary states and a browser assertion that a visible rationale never equals the headline. Closes #493. Co-Authored-By: Claude Fable 5 --- src/observability/dashboard/state/overview.js | 9 +- .../dashboard/ui/pages/command-center.js | 8 +- tests/browser/dashboard-493-hero.test.js | 120 ++++++++++++++++++ tests/hub-overview-rationale-493.test.js | 41 ++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/browser/dashboard-493-hero.test.js create mode 100644 tests/hub-overview-rationale-493.test.js diff --git a/src/observability/dashboard/state/overview.js b/src/observability/dashboard/state/overview.js index 238a9a21..aa65a153 100644 --- a/src/observability/dashboard/state/overview.js +++ b/src/observability/dashboard/state/overview.js @@ -135,12 +135,19 @@ export function buildOverviewProjection(state) { const run = focusRun(state.runs); const readiness = state.readiness ?? { status: 'unknown', coverage: {}, blockers: [] }; const stale = Boolean(run?.pipelineRollup?.stale); + const title = run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.'; + const summary = readiness.summary ?? null; return { focusRunId: run?.runId ?? null, goal: run?.manifest?.goal ?? run?.goal ?? null, outcome: readiness.status ?? 'unknown', - title: run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.', + title, + // #493: the hero sub-line exists only when it adds information — with a + // focused run the title already IS the readiness summary, so echoing the + // summary as rationale rendered the same sentence twice. Server-owned: + // pages render `rationale`, they never re-derive it. + rationale: summary && summary !== title ? summary : null, nextAction: pipelineAction(run, { ...readiness, actions: state.actions ?? [] }), stages: run ? CANONICAL_SDLC_STAGES.map((stage) => stageView(run, stage)) : [], actionCount: state.actions diff --git a/src/observability/dashboard/ui/pages/command-center.js b/src/observability/dashboard/ui/pages/command-center.js index 881ce8e6..e3f57d6d 100644 --- a/src/observability/dashboard/ui/pages/command-center.js +++ b/src/observability/dashboard/ui/pages/command-center.js @@ -106,7 +106,13 @@ function renderOverviewDecisionSurface(s) { setClass('overview-state', 'overview-state ' + status); setText('overview-goal', overview.goal || (noRun ? 'No active run' : 'Goal unavailable')); setText('overview-outcome-title', title); - setText('overview-rationale', readiness.summary || title); + // #493: the rationale is server-owned and null when it would repeat the + // title — never re-derive it from readiness.summary (that fallback painted + // the same sentence twice). + var rationale = overview.rationale || ''; + setText('overview-rationale', rationale); + var rationaleEl = document.getElementById('overview-rationale'); + if (rationaleEl) rationaleEl.hidden = !rationale; setText('overview-coverage', overviewCoverageText(readiness.coverage || overview.coverage)); setText('overview-evaluated-at', overviewTime(overview.evaluatedAt || readiness.evaluatedAt)); setText('overview-action-count', String(overview.actionCount || 0)); diff --git a/tests/browser/dashboard-493-hero.test.js b/tests/browser/dashboard-493-hero.test.js new file mode 100644 index 00000000..04efe9f2 --- /dev/null +++ b/tests/browser/dashboard-493-hero.test.js @@ -0,0 +1,120 @@ +/** + * #493 — the Command Center hero must never render the same sentence as + * + * both headline and rationale. + * + * Same harness as dashboard-96: real server on an ephemeral port, canonical + * fixtures, real Chromium via playwright-core; skips cleanly with no browser. + * + * owner: RStack developed by Richardson Gunde + */ + +/* global document, window */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js'; + +const require = createRequire(import.meta.url); +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js'); + +let chromium = null; +try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } + +function startServer(root) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { + cwd: root, + env: { + ...process.env, + RSTACK_APPROVAL_TOKEN: undefined, + RSTACK_DASHBOARD_READ_TOKEN: undefined, + RSTACK_TLS_CERT: undefined, + RSTACK_TLS_KEY: undefined, + RSTACK_BUSINESS_PORT: undefined, + RSTACK_PROJECT_ROOT: undefined, + RSTACK_NO_BROWSER: '1', + RSTACK_REGISTRY_DIR: join(root, '.registry'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } + }, 15_000); + child.stdout.on('data', (chunk) => { + out += chunk; + const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); + if (match && !settled) { + settled = true; + clearTimeout(timer); + resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') }); + } + }); + child.stderr.on('data', (chunk) => { out += chunk; }); + child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } }); + }); +} + +let browser = null; +test.before(async () => { + if (!chromium) return; + try { browser = await chromium.launch({ headless: true }); } catch { browser = null; } +}); +test.after(async () => { if (browser) await browser.close(); }); + +function browserTest(name, seed, body) { + test(name, async (t) => { + if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; } + const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-')); + let server = null; + const context = await browser.newContext(); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push(String(err))); + try { + await seed(root); + server = await startServer(root); + await body({ page, server, t }); + assert.deepEqual(pageErrors, [], 'no uncaught page errors'); + } finally { + await context.close(); + if (server) server.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); +} + +const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); }; + +async function gotoDashboard(page, server) { + await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 }); +} + +browserTest('the Command Center hero never repeats its headline as the rationale (#493)', seedRichProject, async ({ page, server }) => { + await gotoDashboard(page, server); + const hero = await page.evaluate(() => { + const norm = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''); + const rationale = document.getElementById('overview-rationale'); + return { + title: norm(document.getElementById('overview-outcome-title')), + rationale: norm(rationale), + rationaleHidden: Boolean(rationale && rationale.hidden), + }; + }); + assert.ok(hero.title.length > 0, 'the hero has a headline'); + if (!hero.rationaleHidden && hero.rationale.length > 0) { + assert.notEqual(hero.rationale, hero.title, + 'a visible rationale must say something the headline does not'); + } +}); diff --git a/tests/hub-overview-rationale-493.test.js b/tests/hub-overview-rationale-493.test.js new file mode 100644 index 00000000..a3b8eb79 --- /dev/null +++ b/tests/hub-overview-rationale-493.test.js @@ -0,0 +1,41 @@ +/** + * #493 — the overview projection owns a `rationale` that is null whenever it + * would merely repeat the title, so the Command Center hero can never render + * the same sentence twice. + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildOverviewProjection } from '../src/observability/dashboard/state/overview.js'; + +const READY_SUMMARY = 'Release is blocked by 6 source-backed conditions.'; + +test('overview rationale is null when it would repeat the title (#493)', () => { + const projection = buildOverviewProjection({ + runs: [{ runId: 'run-1', manifest: { goal: 'g' } }], + readiness: { status: 'blocked', summary: READY_SUMMARY, blockers: [], coverage: {} }, + }); + assert.equal(projection.title, READY_SUMMARY, 'title carries the readiness summary'); + assert.equal(projection.rationale, null, 'rationale must not duplicate the title'); +}); + +test('overview rationale keeps a summary that genuinely adds information (#493)', () => { + const projection = buildOverviewProjection({ + runs: [], + readiness: { status: 'unknown', summary: 'No governed run has produced evidence yet.', blockers: [], coverage: {} }, + }); + assert.equal(projection.title, 'No delivery run has been evaluated.'); + assert.equal(projection.rationale, 'No governed run has produced evidence yet.', + 'a distinct summary stays as the rationale'); +}); + +test('overview rationale is null when readiness has no summary (#493)', () => { + const projection = buildOverviewProjection({ + runs: [{ runId: 'run-1', manifest: { goal: 'g' } }], + readiness: { status: 'unknown', blockers: [], coverage: {} }, + }); + assert.equal(projection.rationale, null, 'no invented rationale'); +}); From 502c5c5259731999c9bc1e95ce2c8579d7eab20e Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Fri, 31 Jul 2026 09:47:01 +0530 Subject: [PATCH 3/4] =?UTF-8?q?Hub=20styles:=20global=20[hidden]=20guard?= =?UTF-8?q?=20=E2=80=94=20the=20attribute=20always=20wins=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderRunWorkspace correctly sets empty.hidden = true when a run is scoped, but .run-workspace-empty's own display:grid (an author rule) overrides the UA stylesheet's [hidden] { display:none }, so the "Select one run to open its workspace" prompt stayed visible above the opened workspace. Two classes already carried hand-written [hidden] twins (.secondary-nav, .run-workspace-panel); this class was the one that didn't — a whack-a-mole class of bug. Close the class instead of adding a third twin: a global [hidden] { display:none !important; } guard, the identical pattern the studio3d stylesheet has carried since #431 (studio3d/styles.css). Audit of every hidden-attribute toggle site (secondary-nav, run-workspace panels / empty / content, overview-rationale, studio3d, mobile nav) confirmed nothing relies on being visible while carrying hidden; the two elements shown via inline style.display (#err, #studio-inspector) never carry the attribute. The existing per-class twins stay (now redundant, harmless). Pinned by a browser assertion that the scoped workspace renders with the empty prompt taking zero space. Closes #492. Co-Authored-By: Claude Fable 5 --- src/observability/dashboard/ui/styles.js | 5 + .../dashboard-492-workspace-empty.test.js | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 tests/browser/dashboard-492-workspace-empty.test.js diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index 74cdd796..26d72290 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -17,6 +17,11 @@ export const styles = ` --ink: #111827; } * { box-sizing: border-box; } +/* #492: the hidden attribute must always win. Author display rules on a + class (e.g. .run-workspace-empty's display:grid) otherwise beat the UA's + [hidden] { display:none } and the element stays visible despite + el.hidden = true. Same global guard as studio3d/styles.css. */ +[hidden] { display: none !important; } html, body { margin: 0; height: 100%; } body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; diff --git a/tests/browser/dashboard-492-workspace-empty.test.js b/tests/browser/dashboard-492-workspace-empty.test.js new file mode 100644 index 00000000..771286c4 --- /dev/null +++ b/tests/browser/dashboard-492-workspace-empty.test.js @@ -0,0 +1,132 @@ +/** + * #492 — with a run scoped, the Run Workspace "Select one run" empty prompt + * + * must actually disappear — the `hidden` attribute was defeated by the + * class's own `display:grid` (author styles beat the UA [hidden] rule). + * + * Same harness as dashboard-96: real server on an ephemeral port, canonical + * fixtures, real Chromium via playwright-core; skips cleanly with no browser. + * + * owner: RStack developed by Richardson Gunde + */ + +/* global document, window */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js'; + +const require = createRequire(import.meta.url); +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js'); + +let chromium = null; +try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } + +function startServer(root) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { + cwd: root, + env: { + ...process.env, + RSTACK_APPROVAL_TOKEN: undefined, + RSTACK_DASHBOARD_READ_TOKEN: undefined, + RSTACK_TLS_CERT: undefined, + RSTACK_TLS_KEY: undefined, + RSTACK_BUSINESS_PORT: undefined, + RSTACK_PROJECT_ROOT: undefined, + RSTACK_NO_BROWSER: '1', + RSTACK_REGISTRY_DIR: join(root, '.registry'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } + }, 15_000); + child.stdout.on('data', (chunk) => { + out += chunk; + const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); + if (match && !settled) { + settled = true; + clearTimeout(timer); + resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') }); + } + }); + child.stderr.on('data', (chunk) => { out += chunk; }); + child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } }); + }); +} + +let browser = null; +test.before(async () => { + if (!chromium) return; + try { browser = await chromium.launch({ headless: true }); } catch { browser = null; } +}); +test.after(async () => { if (browser) await browser.close(); }); + +function browserTest(name, seed, body) { + test(name, async (t) => { + if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; } + const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-')); + let server = null; + const context = await browser.newContext(); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push(String(err))); + try { + await seed(root); + server = await startServer(root); + await body({ page, server, t }); + assert.deepEqual(pageErrors, [], 'no uncaught page errors'); + } finally { + await context.close(); + if (server) server.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); +} + +const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); }; + +async function gotoDashboard(page, server) { + await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 }); +} + +browserTest('scoping a run hides the Run Workspace empty prompt for real (#492)', seedRichProject, async ({ page, server }) => { + await gotoDashboard(page, server); + await page.evaluate(() => { + const buttons = [...document.querySelectorAll('nav button[data-page="run-workspace"]')]; + (buttons.find((b) => b.offsetParent !== null) || buttons[0]).click(); + }); + // Scope to the first concrete run via the topbar select (the real user path). + await page.selectOption('#scope-run', { index: 1 }); + await page.waitForFunction(() => { + const content = document.getElementById('run-workspace-content'); + return content && !content.hidden; + }, null, { timeout: 5000 }); + + const visibility = await page.evaluate(() => { + const rect = (el) => el ? el.getBoundingClientRect() : null; + const empty = document.getElementById('run-workspace-empty'); + const emptyRect = rect(empty); + return { + emptyHiddenAttr: Boolean(empty && empty.hidden), + emptyVisiblyRendered: Boolean(emptyRect && emptyRect.height > 0 && emptyRect.width > 0), + contentVisible: Boolean(document.getElementById('run-workspace-content') && !document.getElementById('run-workspace-content').hidden), + }; + }); + assert.equal(visibility.contentVisible, true, 'the scoped workspace is open'); + assert.equal(visibility.emptyHiddenAttr, true, 'the empty prompt carries the hidden attribute'); + assert.equal(visibility.emptyVisiblyRendered, false, + 'the hidden empty prompt takes no space — [hidden] must not be defeated by the class display rule'); +}); From 5b211aac719d1b6319c43227104249119e6a04ca Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Fri, 31 Jul 2026 10:38:21 +0530 Subject: [PATCH 4/4] Review fixes: named time-conversion and timeout constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo (convention, PR #490 precedent): humanizeDurationMs and formatSnapshotAge now branch on named constants (MS_PER_MINUTE, MINUTES_PER_HOUR, HOURS_PER_DAY) — the two formatters are meant to stay in step, and named thresholds make drift visible at a glance. Browser-test timeouts are named constants in both new test files. Refs #491 #492 #493 (PR #508 review follow-up). Co-Authored-By: Claude Fable 5 --- src/observability/alerts/engine.js | 14 +++++++++----- src/observability/dashboard/ui/studio3d/model.js | 14 +++++++++----- .../browser/dashboard-492-workspace-empty.test.js | 8 ++++++-- tests/browser/dashboard-493-hero.test.js | 8 ++++++-- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/observability/alerts/engine.js b/src/observability/alerts/engine.js index 9f5b6478..3701217a 100644 --- a/src/observability/alerts/engine.js +++ b/src/observability/alerts/engine.js @@ -13,12 +13,16 @@ export const DEFAULT_ALERT_THRESHOLDS = Object.freeze({ // never an unbounded minute dump like "42431 min". The studio3d freshness // chip (ui/studio3d/model.js formatSnapshotAge) mirrors this shape; keep the // two in step if the format ever changes. +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; +const HOURS_PER_DAY = 24; + export function humanizeDurationMs(ms) { - const minutes = Math.floor(Math.max(0, Number(ms) || 0) / 60_000); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ${minutes % 60}m`; - return `${Math.floor(hours / 24)}d ${hours % 24}h`; + const minutes = Math.floor(Math.max(0, Number(ms) || 0) / MS_PER_MINUTE); + if (minutes < MINUTES_PER_HOUR) return `${minutes}m`; + const hours = Math.floor(minutes / MINUTES_PER_HOUR); + if (hours < HOURS_PER_DAY) return `${hours}h ${minutes % MINUTES_PER_HOUR}m`; + return `${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h`; } export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { diff --git a/src/observability/dashboard/ui/studio3d/model.js b/src/observability/dashboard/ui/studio3d/model.js index 682cbc79..490fe9dd 100644 --- a/src/observability/dashboard/ui/studio3d/model.js +++ b/src/observability/dashboard/ui/studio3d/model.js @@ -33,6 +33,10 @@ export function statusLabel(value) { return status.charAt(0).toUpperCase() + status.slice(1); } +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; +const HOURS_PER_DAY = 24; + export function formatSnapshotAge(freshness) { if (!freshness?.observed_at) return 'Snapshot time unavailable'; const age = Number(freshness.age_ms); @@ -42,11 +46,11 @@ export function formatSnapshotAge(freshness) { // #491: hours and days tiers — a month-old snapshot must never render as // an unbounded "42433m ago". Same shape as alerts/engine.js // humanizeDurationMs; keep the two in step if the format ever changes. - const minutes = Math.floor(age / 60_000); - if (minutes < 60) return `Observed ${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `Observed ${hours}h ${minutes % 60}m ago`; - return `Observed ${Math.floor(hours / 24)}d ${hours % 24}h ago`; + const minutes = Math.floor(age / MS_PER_MINUTE); + if (minutes < MINUTES_PER_HOUR) return `Observed ${minutes}m ago`; + const hours = Math.floor(minutes / MINUTES_PER_HOUR); + if (hours < HOURS_PER_DAY) return `Observed ${hours}h ${minutes % MINUTES_PER_HOUR}m ago`; + return `Observed ${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h ago`; } export function entityRef(kind, id) { diff --git a/tests/browser/dashboard-492-workspace-empty.test.js b/tests/browser/dashboard-492-workspace-empty.test.js index 771286c4..5d206ac9 100644 --- a/tests/browser/dashboard-492-workspace-empty.test.js +++ b/tests/browser/dashboard-492-workspace-empty.test.js @@ -30,6 +30,10 @@ const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', let chromium = null; try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } +// Named per the repo's magic-number convention (PR #490). +const SERVER_BOOT_TIMEOUT_MS = 15_000; +const STATE_LOAD_TIMEOUT_MS = 10_000; + function startServer(root) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { @@ -51,7 +55,7 @@ function startServer(root) { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } - }, 15_000); + }, SERVER_BOOT_TIMEOUT_MS); child.stdout.on('data', (chunk) => { out += chunk; const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); @@ -99,7 +103,7 @@ const seedRichProject = async (root) => { await fixtureReadyRun(root); await fix async function gotoDashboard(page, server) { await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); - await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS }); } browserTest('scoping a run hides the Run Workspace empty prompt for real (#492)', seedRichProject, async ({ page, server }) => { diff --git a/tests/browser/dashboard-493-hero.test.js b/tests/browser/dashboard-493-hero.test.js index 04efe9f2..10643a5a 100644 --- a/tests/browser/dashboard-493-hero.test.js +++ b/tests/browser/dashboard-493-hero.test.js @@ -29,6 +29,10 @@ const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', let chromium = null; try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } +// Named per the repo's magic-number convention (PR #490). +const SERVER_BOOT_TIMEOUT_MS = 15_000; +const STATE_LOAD_TIMEOUT_MS = 10_000; + function startServer(root) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { @@ -50,7 +54,7 @@ function startServer(root) { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } - }, 15_000); + }, SERVER_BOOT_TIMEOUT_MS); child.stdout.on('data', (chunk) => { out += chunk; const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); @@ -98,7 +102,7 @@ const seedRichProject = async (root) => { await fixtureReadyRun(root); await fix async function gotoDashboard(page, server) { await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); - await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS }); } browserTest('the Command Center hero never repeats its headline as the rationale (#493)', seedRichProject, async ({ page, server }) => {