From cabd224987cbfd55c5dd81d7d893b2efed626363 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 1 Apr 2026 14:48:47 +0100 Subject: [PATCH] fix(frontend): sync summary hover cursor and proof --- .../subsystems/deployment-installability.md | 7 + .../subsystems/frontend-primitives.md | 2 +- .../subsystems/performance-and-scalability.md | 4 +- .../internal/subsystems/unified-resources.md | 7 + .../Infrastructure/InfrastructureSummary.tsx | 2 + .../infrastructureSummaryModel.test.ts | 31 + .../Workloads/WorkloadsSummary.test.tsx | 39 +- .../components/Workloads/WorkloadsSummary.tsx | 2 + .../src/components/shared/DensityMap.tsx | 51 +- .../shared/InteractiveSparkline.tsx | 11 +- .../SharedPrimitives.guardrails.test.ts | 2 + .../shared/__tests__/DensityMap.test.tsx | 36 ++ .../__tests__/InteractiveSparkline.test.tsx | 35 ++ .../src/components/shared/densityMapModel.ts | 24 + .../shared/interactiveSparklineModel.ts | 20 + .../components/shared/useDensityMapState.ts | 35 +- .../shared/useInteractiveSparklineState.ts | 34 +- playwright.config.ts | 107 +++- scripts/tests/test-hot-dev-bg.sh | 23 + .../tests/48-summary-hover-selection.spec.ts | 25 + tests/integration/tests/helpers.ts | 533 +++++++++++------- 21 files changed, 773 insertions(+), 257 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 502bc261e..6068883c7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -93,6 +93,13 @@ server-side update execution surfaces. canonical dev reset route, and any helper changes that rely on hot-dev browser/backend behavior must keep a managed-runtime recovery proof updated in the same slice. +5. Keep root-level Playwright wrapper routing on the canonical managed browser + truth. `playwright.config.ts`, `tests/integration/playwright.config.ts`, + and `tests/integration/tests/runtime-defaults.ts` must resolve the same + browser base URL precedence so repo-root browser proofs attach to the live + managed hot-dev shell or runtime-state browser URL instead of silently + falling back to the embedded `:7655` frontend when a managed browser shell + is already the active truth. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 704d6ec6c..86e035ee5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -227,7 +227,7 @@ connections` visible as the API-backed alternative for Proxmox and `frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw route strings or provider-local link builders inside feature panels. 15. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules. -16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and layer focused-entity detail inside the same card instead of swapping the card into a single-series chart or dimming the rest of the map into unusable background noise. +16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and layer focused-entity detail into the card chrome instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or covering the heatmap body with a floating detail overlay. 17. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection. 18. Keep shared infrastructure/resource selectors on the canonical agent-facet truth. Shared primitives and settings-facing selector helpers must treat diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index e51bbd727..3283d14dd 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -202,7 +202,9 @@ chart cross-highlighting reuse one existing chart path instead of repainting page-local “selected row” overlays on top of already downsampled summary history. Hovering a sparkline or density map for one entity must promote that entity into the shared active series so sibling cards highlight the same object -at once rather than maintaining chart-local hover state. +at once rather than maintaining chart-local hover state, and the synchronized +hover timestamp must remain visible across those sibling cards even when the +active entity has no samples for one metric in the current range. For shared line charts on that hot path, the shared sparkline primitive may isolate the selected series inside the existing render budget, but that isolation must still reuse the same summary series set and timeline data rather diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 090f5e1df..57fdc0079 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -184,6 +184,13 @@ assembly branch. shared route-state scheduler, and keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` rendering the full page-level series set while only the focused label and highlight state change. +11. Keep infrastructure summary hover scope on canonical unified-resource ids + even when one metric is empty. Shared chart hover may synchronize one + timestamp across all four infrastructure cards, but the active emphasis + must still resolve through the same unified-resource id that powers the + table row, line charts, density maps, and drawer route state instead of + dropping the highlight or inventing a metric-local summary identity when + disk or network data is missing in range. ## Current State diff --git a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx index 947016673..e87889351 100644 --- a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx @@ -112,6 +112,7 @@ export const InfrastructureSummary: Component = (pro rangeLabel={rangeLabel()} timeRange={props.timeRange} formatValue={formatThroughputRate} + focusEmptyStateLabel="No disk activity in range" hoverSourceKey="diskio" hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} @@ -187,6 +188,7 @@ export const InfrastructureSummary: Component = (pro rangeLabel={rangeLabel()} timeRange={props.timeRange} formatValue={formatThroughputRate} + focusEmptyStateLabel="No network activity in range" hoverSourceKey="network" hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts index c2d14ceb8..34f468e30 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts @@ -172,6 +172,37 @@ describe('infrastructureSummaryModel', () => { ]); }); + it('keeps the same canonical resource id on empty metric series so shared hover can stay page-scoped', () => { + const displayedSeries = buildInfrastructureDisplaySeries( + [ + makeSeries('host-1', { + name: 'Host 1', + cpu: [{ timestamp: 1, value: 20 }], + memory: [{ timestamp: 1, value: 35 }], + network: [], + diskio: [], + }), + makeSeries('host-2', { + name: 'Host 2', + cpu: [{ timestamp: 2, value: 40 }], + memory: [{ timestamp: 2, value: 55 }], + network: [{ timestamp: 2, value: 240 }], + diskio: [{ timestamp: 2, value: 90 }], + }), + ], + null, + ); + + expect(buildInfrastructureMetricSeries(displayedSeries, 'network')).toEqual([ + { id: 'host-1', data: [], color: '#00aaff', name: 'Host 1' }, + { id: 'host-2', data: [{ timestamp: 2, value: 240 }], color: '#00aaff', name: 'Host 2' }, + ]); + expect(buildInfrastructureMetricSeries(displayedSeries, 'diskio')).toEqual([ + { id: 'host-1', data: [], color: '#00aaff', name: 'Host 1' }, + { id: 'host-2', data: [{ timestamp: 2, value: 90 }], color: '#00aaff', name: 'Host 2' }, + ]); + }); + it('uses one canonical active series id across hover and focused summary selection', () => { expect( resolveSummaryActiveSeriesId({ diff --git a/frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx b/frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx index 4b709f9eb..c6ca84e3e 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx @@ -27,12 +27,14 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({ interactionState?: string; activeSeriesDisplay?: string; hoverSourceKey?: string; - hoverSync?: { seriesId: string } | null; - onHoverSyncChange?: (value: { - sourceKey: string; - seriesId: string; - timestamp: number; - } | null) => void; + hoverSync?: { seriesId: string; timestamp?: number } | null; + onHoverSyncChange?: ( + value: { + sourceKey: string; + seriesId: string; + timestamp: number; + } | null, + ) => void; }) => { const series = props.series ?? []; const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0); @@ -63,6 +65,9 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({ data-highlight-series-id={props.highlightSeriesId || ''} data-hover-source-key={props.hoverSourceKey || ''} data-hover-sync-series-id={props.hoverSync?.seriesId || ''} + data-hover-sync-timestamp={ + props.hoverSync?.timestamp ? String(props.hoverSync.timestamp) : '' + } data-interaction-state={props.interactionState || 'default'} data-active-series-display={props.activeSeriesDisplay || ''} /> @@ -77,12 +82,14 @@ vi.mock('@/components/shared/DensityMap', () => ({ highlightSeriesId?: string | null; interactionState?: string; hoverSourceKey?: string; - hoverSync?: { seriesId: string } | null; - onHoverSyncChange?: (value: { - sourceKey: string; - seriesId: string; - timestamp: number; - } | null) => void; + hoverSync?: { seriesId: string; timestamp?: number } | null; + onHoverSyncChange?: ( + value: { + sourceKey: string; + seriesId: string; + timestamp: number; + } | null, + ) => void; }) => { const series = props.series ?? []; const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0); @@ -113,6 +120,9 @@ vi.mock('@/components/shared/DensityMap', () => ({ data-highlight-series-id={props.highlightSeriesId || ''} data-hover-source-key={props.hoverSourceKey || ''} data-hover-sync-series-id={props.hoverSync?.seriesId || ''} + data-hover-sync-timestamp={ + props.hoverSync?.timestamp ? String(props.hoverSync.timestamp) : '' + } data-interaction-state={props.interactionState || 'default'} /> @@ -389,9 +399,14 @@ describe('WorkloadsSummary performance behavior', () => { await waitFor(() => { const sparklines = screen.getAllByTestId('sparkline'); expect(sparklines).toHaveLength(4); + const timestamps = new Set( + sparklines.map((sparkline) => sparkline.getAttribute('data-hover-sync-timestamp')), + ); + expect(timestamps.size).toBe(1); for (const sparkline of sparklines) { expect(sparkline.getAttribute('data-highlight-series-id')).toBe(workloadIds[0]); expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe(workloadIds[0]); + expect(sparkline.getAttribute('data-hover-sync-timestamp')).not.toBe(''); expect(sparkline.getAttribute('data-interaction-state')).toBe('active'); } }); diff --git a/frontend-modern/src/components/Workloads/WorkloadsSummary.tsx b/frontend-modern/src/components/Workloads/WorkloadsSummary.tsx index 6ead44e93..86b3f612a 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSummary.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSummary.tsx @@ -811,6 +811,7 @@ export const WorkloadsSummary: Component = (props) => { rangeLabel={selectedRange()} timeRange={selectedRange()} formatValue={formatThroughputRate} + focusEmptyStateLabel="No disk activity in range" hoverSourceKey="diskio" hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} @@ -832,6 +833,7 @@ export const WorkloadsSummary: Component = (props) => { rangeLabel={selectedRange()} timeRange={selectedRange()} formatValue={formatThroughputRate} + focusEmptyStateLabel="No network activity in range" hoverSourceKey="network" hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} diff --git a/frontend-modern/src/components/shared/DensityMap.tsx b/frontend-modern/src/components/shared/DensityMap.tsx index 3c9acf62a..9a601e3a2 100644 --- a/frontend-modern/src/components/shared/DensityMap.tsx +++ b/frontend-modern/src/components/shared/DensityMap.tsx @@ -1,6 +1,10 @@ import { Component, Show } from 'solid-js'; import { Portal } from 'solid-js/web'; -import { formatDensityMapHoverTime, type DensityMapProps } from './densityMapModel'; +import { + formatDensityMapHoverTime, + hasDensityMapFocusActivity, + type DensityMapProps, +} from './densityMapModel'; import { useDensityMapState } from './useDensityMapState'; export type { DensityMapProps } from './densityMapModel'; @@ -17,6 +21,7 @@ export const DensityMap: Component = (props) => { interactionState() === 'inactive' ? 'opacity-35' : 'opacity-100' }`.trim()} data-summary-chart-kind="density-map" + data-active-hover-timestamp={densityMap.activeHoverTimestamp() ?? ''} data-highlight-series-id={props.highlightSeriesId ?? ''} data-highlight-series-active={densityMap.externalSeriesIndex() !== null ? 'true' : 'false'} data-rendered-series-count={densityMap.chartData().series.length} @@ -35,6 +40,7 @@ export const DensityMap: Component = (props) => {
@@ -64,24 +70,33 @@ export const DensityMap: Component = (props) => { )}
-
-
- - Latest - - - {formatDetailValue(detail().currentValue)} - + + {props.focusEmptyStateLabel ?? 'No activity in range'} +
+ } + > +
+
+ + Latest + + + {formatDetailValue(detail().currentValue)} + +
+
+ + Peak + + + {formatDetailValue(detail().peakValue)} + +
-
- - Peak - - - {formatDetailValue(detail().peakValue)} - -
-
+
)} diff --git a/frontend-modern/src/components/shared/InteractiveSparkline.tsx b/frontend-modern/src/components/shared/InteractiveSparkline.tsx index ca08ff2b8..cd6cbda25 100644 --- a/frontend-modern/src/components/shared/InteractiveSparkline.tsx +++ b/frontend-modern/src/components/shared/InteractiveSparkline.tsx @@ -34,6 +34,7 @@ export const InteractiveSparkline: Component = (props }`.trim()} data-highlight-series-id={props.highlightSeriesId ?? ''} data-highlight-series-active={sparkline.externalSeriesIndex() !== null ? 'true' : 'false'} + data-active-hover-timestamp={sparkline.activeHoverTimestamp() ?? ''} data-active-series-display={sparkline.activeSeriesDisplay()} data-rendered-series-count={sparkline.renderedSeriesCount()} data-summary-chart-state={interactionState()} @@ -118,12 +119,12 @@ export const InteractiveSparkline: Component = (props )} - - {(hover) => ( + + {(cursorX) => ( { expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData'); expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState'); + expect(interactiveSparklineModelSource).toContain('getInteractiveSparklineCursorXForTimestamp'); expect(interactiveSparklineModelSource).toContain('downsampleLTTB'); expect(interactiveSparklineModelSource).toContain('findNearestMetricPoint'); }); @@ -577,6 +578,7 @@ describe('shared primitive guardrails', () => { expect(densityMapModelSource).toContain('buildDensityMapFocusDetail'); expect(densityMapModelSource).toContain('buildDensityMapHoveredState'); expect(densityMapModelSource).toContain('formatDensityMapHoverTime'); + expect(densityMapModelSource).toContain('getDensityMapColumnIndexForTimestamp'); expect(densityMapModelSource).toContain('getDensityMapCellOpacity'); }); diff --git a/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx b/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx index ae896f2f3..7f908a441 100644 --- a/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/DensityMap.test.tsx @@ -244,6 +244,7 @@ describe('DensityMap', () => { const root = container.firstElementChild; expect(root?.getAttribute('data-summary-chart-kind')).toBe('density-map'); + expect(root?.getAttribute('data-active-hover-timestamp')).toBe(''); expect(root?.getAttribute('data-rendered-series-count')).toBe('2'); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Latest')).toBeInTheDocument(); @@ -253,4 +254,39 @@ describe('DensityMap', () => { expect(overlay).not.toBeNull(); expect(overlay).toHaveAttribute('data-density-map-focus-series-id', 'alpha'); }); + + it('shows an intentional empty-state pill when the focused series has no activity in range', () => { + const now = Date.now(); + const { container } = render(() => ( + + )); + + const overlay = container.querySelector('[data-density-map-focus-detail="true"]'); + expect(overlay).not.toBeNull(); + expect(overlay).toHaveAttribute('data-density-map-focus-empty', 'true'); + expect(screen.getByText('No disk activity in range')).toBeInTheDocument(); + expect(screen.queryByText('Latest')).toBeNull(); + expect(screen.queryByText('Peak')).toBeNull(); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/InteractiveSparkline.test.tsx b/frontend-modern/src/components/shared/__tests__/InteractiveSparkline.test.tsx index e11b29a94..b41118ceb 100644 --- a/frontend-modern/src/components/shared/__tests__/InteractiveSparkline.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/InteractiveSparkline.test.tsx @@ -129,6 +129,41 @@ describe('InteractiveSparkline hover behavior', () => { expect(onHoverSyncChange).toHaveBeenLastCalledWith(null); }); + it('keeps a synchronized hover timestamp visible even when the synced series is absent locally', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); + const now = Date.now(); + + const { container } = render(() => ( + + )); + + const root = container.firstElementChild; + expect(root?.getAttribute('data-active-hover-timestamp')).toBe(String(now - 10_000)); + const svg = container.querySelector('svg'); + expect(svg?.querySelector('line[stroke-dasharray="3 3"]')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).toBeNull(); + }); + it('limits tooltip rows and shows the "+N more series" affordance', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); diff --git a/frontend-modern/src/components/shared/densityMapModel.ts b/frontend-modern/src/components/shared/densityMapModel.ts index 59d6c4330..af7c20153 100644 --- a/frontend-modern/src/components/shared/densityMapModel.ts +++ b/frontend-modern/src/components/shared/densityMapModel.ts @@ -9,6 +9,7 @@ export interface DensityMapProps { rangeLabel?: string; timeRange?: TimeRange; formatValue?: (value: number) => string; + focusEmptyStateLabel?: string; hoverSourceKey?: string; hoverSync?: SummaryChartHoverSync | null; onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void; @@ -167,6 +168,25 @@ export function getDensityMapExternalSeriesIndex( return index >= 0 ? index : null; } +export function getDensityMapColumnIndexForTimestamp( + data: DensityMapChartData, + timestamp: number | null | undefined, +): number | null { + if (timestamp === null || timestamp === undefined || data.rangeMs <= 0) { + return null; + } + const clampedTimestamp = clampDensityMapValue( + timestamp, + data.windowStart, + data.windowStart + data.rangeMs, + ); + return clampDensityMapValue( + Math.floor(((clampedTimestamp - data.windowStart) / data.rangeMs) * DENSITY_MAP_COLUMNS), + 0, + DENSITY_MAP_COLUMNS - 1, + ); +} + export function buildDensityMapHoveredState(options: { clientX: number; clientY: number; @@ -285,6 +305,10 @@ export function buildDensityMapFocusDetail(options: { }; } +export function hasDensityMapFocusActivity(detail: DensityMapFocusDetail): boolean { + return detail.currentValue !== null || detail.peakValue !== null; +} + const buildDensityMapFocusSparklinePath = (options: { points: Array<{ timestamp: number; value: number }>; rangeMs: number; diff --git a/frontend-modern/src/components/shared/interactiveSparklineModel.ts b/frontend-modern/src/components/shared/interactiveSparklineModel.ts index c42d829ee..d0f6e9d01 100644 --- a/frontend-modern/src/components/shared/interactiveSparklineModel.ts +++ b/frontend-modern/src/components/shared/interactiveSparklineModel.ts @@ -432,6 +432,26 @@ export const getInteractiveSparklineExternalSeriesIndex = ( return index >= 0 ? index : null; }; +export const getInteractiveSparklineCursorXForTimestamp = ({ + chartData, + timestamp, + vbW, +}: { + chartData: InteractiveSparklineChartData; + timestamp: number | null | undefined; + vbW: number; +}): number | null => { + if (timestamp === null || timestamp === undefined || chartData.rangeMs <= 0) { + return null; + } + const clampedTimestamp = clampInteractiveSparklineValue( + timestamp, + chartData.windowStart, + chartData.windowStart + chartData.rangeMs, + ); + return ((clampedTimestamp - chartData.windowStart) / chartData.rangeMs) * vbW; +}; + export const getInteractiveSparklineActiveEmphasisSeriesIndex = ({ highlightNearestSeriesOnHover, lockedSeriesIndex, diff --git a/frontend-modern/src/components/shared/useDensityMapState.ts b/frontend-modern/src/components/shared/useDensityMapState.ts index a1f6fa124..13672e111 100644 --- a/frontend-modern/src/components/shared/useDensityMapState.ts +++ b/frontend-modern/src/components/shared/useDensityMapState.ts @@ -2,8 +2,9 @@ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import { buildDensityMapChartData, buildDensityMapFocusDetail, - getDensityMapExternalSeriesIndex, buildDensityMapHoveredState, + getDensityMapColumnIndexForTimestamp, + getDensityMapExternalSeriesIndex, buildDensityMapSynchronizedHoveredState, formatDensityMapValue, getDensityMapCellOpacity, @@ -41,9 +42,25 @@ export function useDensityMapState(props: DensityMapProps) { hoverSync, }); }); + const synchronizedHoverTimestamp = createMemo(() => { + const hoverSync = props.hoverSync; + if (!hoverSync) { + return null; + } + if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) { + return null; + } + return hoverSync.timestamp; + }); const activeHoveredState = createMemo(() => { return hoveredState() ?? synchronizedHoveredState(); }); + const activeHoverTimestamp = createMemo(() => { + return hoveredState()?.timestamp ?? synchronizedHoverTimestamp(); + }); + const activeColumnIndex = createMemo(() => + getDensityMapColumnIndexForTimestamp(chartData(), activeHoverTimestamp()), + ); const focusDetail = createMemo(() => buildDensityMapFocusDetail({ activeHoveredState: activeHoveredState(), @@ -81,6 +98,7 @@ export function useDensityMapState(props: DensityMapProps) { const cellHeight = height / rows; const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1; const hover = activeHoveredState(); + const highlightColumnIndex = activeColumnIndex(); const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex(); const activeSeries = activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length @@ -137,8 +155,8 @@ export function useDensityMapState(props: DensityMapProps) { if (activeSeries !== null && activeSeriesIndex !== null) { const highlightY = activeSeriesIndex * cellHeight; context.save(); - if (hover) { - const highlightX = hover.columnIndex * cellWidth; + if (highlightColumnIndex !== null) { + const highlightX = highlightColumnIndex * cellWidth; context.globalAlpha = 0.12 * interactionOpacity; context.fillStyle = activeSeries.color; context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height); @@ -159,6 +177,15 @@ export function useDensityMapState(props: DensityMapProps) { context.restore(); } + if (activeSeries === null && highlightColumnIndex !== null) { + context.save(); + const highlightX = highlightColumnIndex * cellWidth; + context.globalAlpha = 0.12 * interactionOpacity; + context.fillStyle = 'rgba(148, 163, 184, 0.75)'; + context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height); + context.restore(); + } + context.globalAlpha = 1; }; @@ -209,6 +236,8 @@ export function useDensityMapState(props: DensityMapProps) { return { activeHoveredState, + activeColumnIndex, + activeHoverTimestamp, chartData, externalSeriesIndex, focusDetail, diff --git a/frontend-modern/src/components/shared/useInteractiveSparklineState.ts b/frontend-modern/src/components/shared/useInteractiveSparklineState.ts index e139dd11f..56c189444 100644 --- a/frontend-modern/src/components/shared/useInteractiveSparklineState.ts +++ b/frontend-modern/src/components/shared/useInteractiveSparklineState.ts @@ -3,6 +3,7 @@ import { scheduleSparkline, setupCanvasDPR } from '@/utils/canvasRenderQueue'; import { buildInteractiveSparklineAxisTicks, buildInteractiveSparklineChartData, + getInteractiveSparklineCursorXForTimestamp, buildInteractiveSparklineGridLineX, buildInteractiveSparklineGridLineY, buildInteractiveSparklineSynchronizedHoverState, @@ -195,9 +196,29 @@ export function useInteractiveSparklineState( yMode: yMode(), }); }); + const synchronizedHoverTimestamp = createMemo(() => { + const hoverSync = props.hoverSync; + if (!hoverSync) { + return null; + } + if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) { + return null; + } + return hoverSync.timestamp; + }); const activeHoverState = createMemo(() => { return hoveredState() ?? synchronizedHoverState(); }); + const activeHoverTimestamp = createMemo(() => { + return hoveredState()?.timestamp ?? synchronizedHoverTimestamp(); + }); + const activeHoverCursorX = createMemo(() => + getInteractiveSparklineCursorXForTimestamp({ + chartData: chartData(), + timestamp: activeHoverTimestamp(), + vbW, + }), + ); const activeEmphasisSeriesIndex = createMemo(() => getInteractiveSparklineActiveEmphasisSeriesIndex({ highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover === true, @@ -385,11 +406,13 @@ export function useInteractiveSparklineState( ctx.restore(); } + const cursorX = activeHoverCursorX(); + if (cursorX === null) return; const hover = activeHoverState(); - if (!hover) return; ctx.save(); - const x = (hover.x / vbW) * width; - const hoverLineGradient = ctx.createLinearGradient(0, Math.max(0, hover.minY - 4), 0, height); + const x = (cursorX / vbW) * width; + const lineStartY = hover ? Math.max(0, hover.minY - 4) : 0; + const hoverLineGradient = ctx.createLinearGradient(0, lineStartY, 0, height); hoverLineGradient.addColorStop(0, 'transparent'); hoverLineGradient.addColorStop(0.1, hoverLineColor); hoverLineGradient.addColorStop(1, hoverLineColor); @@ -397,7 +420,7 @@ export function useInteractiveSparklineState( ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); - ctx.moveTo(x, Math.max(0, hover.minY - 4)); + ctx.moveTo(x, lineStartY); ctx.lineTo(x, height); ctx.stroke(); ctx.restore(); @@ -423,6 +446,7 @@ export function useInteractiveSparklineState( void chartData(); void activeEmphasisSeriesIndex(); void activeHoverState(); + void activeHoverCursorX(); queueCanvasDraw(); }); @@ -449,6 +473,8 @@ export function useInteractiveSparklineState( return { activeEmphasisSeriesIndex, activeHoverState, + activeHoverCursorX, + activeHoverTimestamp, activeSeriesDisplay, axisTicks, chartData, diff --git a/playwright.config.ts b/playwright.config.ts index f3c87c63f..6dd80497d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,10 +9,69 @@ * resolves testDir so that the Playwright runner and test files share the * same @playwright/test instance (avoiding the "two versions" error). */ -import { defineConfig, devices } from '@playwright/test'; +import fs from "node:fs"; +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +const trim = (value: unknown): string => String(value ?? "").trim(); +const repoRoot = path.resolve(__dirname); +const managedHotDevPidPath = path.join(repoRoot, "tmp", "hot-dev.bg.pid"); + +const runtimeStatePath = (env: NodeJS.ProcessEnv = process.env): string => { + const configuredPath = trim(env.PULSE_E2E_RUNTIME_STATE_PATH); + if (configuredPath === "") { + return path.resolve(repoRoot, "tmp", "e2e-runtime-state.json"); + } + return path.isAbsolute(configuredPath) + ? configuredPath + : path.resolve(repoRoot, configuredPath); +}; + +const loadRuntimeBaseURL = ( + env: NodeJS.ProcessEnv = process.env, +): string | null => { + try { + const raw = fs.readFileSync(runtimeStatePath(env), "utf8"); + const parsed = JSON.parse(raw) as { baseURL?: string }; + return typeof parsed.baseURL === "string" && parsed.baseURL.trim() !== "" + ? parsed.baseURL.trim() + : null; + } catch { + return null; + } +}; + +const managedDevBrowserBaseURL = ( + env: NodeJS.ProcessEnv = process.env, +): string | null => { + try { + const pid = Number.parseInt( + fs.readFileSync(managedHotDevPidPath, "utf8").trim(), + 10, + ); + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + process.kill(pid, 0); + const host = trim(env.FRONTEND_DEV_HOST) || "127.0.0.1"; + const port = trim(env.FRONTEND_DEV_PORT) || "5173"; + return `http://${host}:${port}`; + } catch { + return null; + } +}; + +const preferredBrowserBaseURL = ( + env: NodeJS.ProcessEnv = process.env, +): string => + trim(env.PULSE_BASE_URL) || + trim(env.PLAYWRIGHT_BASE_URL) || + loadRuntimeBaseURL(env) || + managedDevBrowserBaseURL(env) || + "http://localhost:7655"; export default defineConfig({ - testDir: './tests/integration/tests', + testDir: "./tests/integration/tests", fullyParallel: false, forbidOnly: !!process.env.CI, @@ -20,46 +79,48 @@ export default defineConfig({ workers: 1, reporter: [ - ['html', { outputFolder: 'tests/integration/playwright-report', open: 'never' }], - ['list'], - ['junit', { outputFile: 'tests/integration/test-results/junit.xml' }], + [ + "html", + { outputFolder: "tests/integration/playwright-report", open: "never" }, + ], + ["list"], + ["junit", { outputFile: "tests/integration/test-results/junit.xml" }], ], timeout: 60_000, expect: { timeout: 10_000 }, use: { - baseURL: - process.env.PULSE_BASE_URL || - process.env.PLAYWRIGHT_BASE_URL || - 'http://localhost:7655', + baseURL: preferredBrowserBaseURL(), - ignoreHTTPSErrors: ['1', 'true', 'yes', 'on'].includes( - String(process.env.PULSE_E2E_INSECURE_TLS || '').trim().toLowerCase(), + ignoreHTTPSErrors: ["1", "true", "yes", "on"].includes( + String(process.env.PULSE_E2E_INSECURE_TLS || "") + .trim() + .toLowerCase(), ), - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", navigationTimeout: 15_000, actionTimeout: 10_000, }, projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - testIgnore: ['**/04-mobile.spec.ts'], + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + testIgnore: ["**/04-mobile.spec.ts"], }, { - name: 'mobile-chrome', - use: { ...devices['Pixel 5'] }, - testIgnore: ['**/journeys/**'], + name: "mobile-chrome", + use: { ...devices["Pixel 5"] }, + testIgnore: ["**/journeys/**"], }, { - name: 'mobile-safari', - use: { ...devices['iPhone 12'] }, - testIgnore: ['**/journeys/**'], + name: "mobile-safari", + use: { ...devices["iPhone 12"] }, + testIgnore: ["**/journeys/**"], }, ], diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh index 1a7b8101c..66607f0f8 100755 --- a/scripts/tests/test-hot-dev-bg.sh +++ b/scripts/tests/test-hot-dev-bg.sh @@ -650,6 +650,28 @@ test_playwright_defaults_prefer_managed_hot_dev_runtime() { assert_contains "playwright config delegates base url to shared helper" "${output}" "baseURL: preferredBrowserBaseURL()," } +test_root_playwright_wrapper_prefers_managed_browser_runtime() { + local test_dir runtime_state output + test_dir="$(mktemp -d)" + temp_dirs+=("${test_dir}") + runtime_state="${test_dir}/runtime-state.json" + + cat > "${runtime_state}" <<'EOF' +{ + "baseURL": "http://127.0.0.1:5173" +} +EOF + + output="$( + cd "${ROOT_DIR}" && \ + PULSE_E2E_RUNTIME_STATE_PATH="${runtime_state}" \ + npx tsx --eval "import config from './playwright.config.ts'; console.log(config.use?.baseURL || '');" + )" + + assert_contains "root playwright wrapper prefers runtime-state browser url" "${output}" "http://127.0.0.1:5173" + assert_not_contains "root playwright wrapper no longer falls back to embedded frontend by default" "${output}" "http://localhost:7655" +} + test_integration_helpers_prefer_managed_hot_dev_runtime() { local output output="$(cat "${ROOT_DIR}/tests/integration/tests/helpers.ts")" @@ -911,6 +933,7 @@ main() { test_hot_dev_bg_script_advertises_managed_entrypoint test_hot_dev_bg_usage_prefers_managed_wrappers test_integration_readme_uses_managed_backend_restart_wrapper + test_root_playwright_wrapper_prefers_managed_browser_runtime test_clean_mock_alerts_prefers_managed_runtime test_dev_check_uses_managed_runtime_status test_backend_restart_requires_managed_runtime diff --git a/tests/integration/tests/48-summary-hover-selection.spec.ts b/tests/integration/tests/48-summary-hover-selection.spec.ts index 4f354432a..85ff94bb9 100644 --- a/tests/integration/tests/48-summary-hover-selection.spec.ts +++ b/tests/integration/tests/48-summary-hover-selection.spec.ts @@ -248,6 +248,28 @@ async function expectActiveDensityMapsPreserveOverview( }); } +async function expectSummaryHoverTimestampsAligned( + summary: import("@playwright/test").Locator, + expectedCount: number, +): Promise { + await expect + .poll(async () => + summary.locator("[data-active-hover-timestamp]").evaluateAll((nodes) => { + const timestamps = nodes + .map((node) => node.getAttribute("data-active-hover-timestamp") || "") + .filter(Boolean); + return { + count: timestamps.length, + uniqueCount: new Set(timestamps).size, + }; + }), + ) + .toEqual({ + count: expectedCount, + uniqueCount: 1, + }); +} + async function readSummarySeriesId( row: import("@playwright/test").Locator, fallbackAttribute: string, @@ -445,6 +467,7 @@ test.describe.serial("Summary hover selection", () => { infrastructureChartId, 4, ); + await expectSummaryHoverTimestampsAligned(infrastructureSummary, 4); await expectActiveIsolatedLineCards(infrastructureSummary, 2); await expectActiveDensityMapsPreserveOverview( infrastructureSummary, @@ -464,6 +487,7 @@ test.describe.serial("Summary hover selection", () => { ); expect(workloadChartId).not.toBe(""); await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4); + await expectSummaryHoverTimestampsAligned(workloadsSummary, 4); await expectActiveIsolatedLineCards(workloadsSummary, 2); await expectActiveDensityMapsPreserveOverview( workloadsSummary, @@ -481,6 +505,7 @@ test.describe.serial("Summary hover selection", () => { ); expect(storageChartId).not.toBe(""); await expectSummaryHighlightCount(storageSummary, storageChartId, 3); + await expectSummaryHoverTimestampsAligned(storageSummary, 4); await expectActiveIsolatedLineCards(storageSummary, 3); }); }); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index c682975d5..887fa6036 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -1,37 +1,42 @@ -import { Browser, Page, expect } from '@playwright/test'; -import { preferredBrowserBaseURL, readRuntimeState } from './runtime-defaults'; +import { Browser, Page, expect } from "@playwright/test"; +import { preferredBrowserBaseURL, readRuntimeState } from "./runtime-defaults"; const runtimePrimaryAPIToken = (): string => { const parsed = readRuntimeState(); - return typeof parsed?.primaryAPIToken === 'string' ? parsed.primaryAPIToken.trim() : ''; + return typeof parsed?.primaryAPIToken === "string" + ? parsed.primaryAPIToken.trim() + : ""; }; /** * Default admin credentials for testing */ export const ADMIN_CREDENTIALS = { - username: 'admin', + username: "admin", // Pulse enforces a minimum password length of 12 characters. - password: 'adminadminadmin', + password: "adminadminadmin", }; -const DEFAULT_E2E_BOOTSTRAP_TOKEN = '0123456789abcdef0123456789abcdef0123456789abcdef'; +const DEFAULT_E2E_BOOTSTRAP_TOKEN = + "0123456789abcdef0123456789abcdef0123456789abcdef"; export const E2E_CREDENTIALS = { - bootstrapToken: process.env.PULSE_E2E_BOOTSTRAP_TOKEN || DEFAULT_E2E_BOOTSTRAP_TOKEN, - primaryApiToken: process.env.PULSE_E2E_PRIMARY_API_TOKEN || runtimePrimaryAPIToken(), + bootstrapToken: + process.env.PULSE_E2E_BOOTSTRAP_TOKEN || DEFAULT_E2E_BOOTSTRAP_TOKEN, + primaryApiToken: + process.env.PULSE_E2E_PRIMARY_API_TOKEN || runtimePrimaryAPIToken(), username: process.env.PULSE_E2E_USERNAME || ADMIN_CREDENTIALS.username, password: process.env.PULSE_E2E_PASSWORD || ADMIN_CREDENTIALS.password, }; async function waitForAppShell(page: Page, timeoutMs = 20_000) { - await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState("domcontentloaded"); // The raw HTML shell contains a noscript fallback. Wait for the SPA to // mount before making route-specific assertions against wizard/login UI. await page.waitForFunction( () => { - const root = document.getElementById('root'); + const root = document.getElementById("root"); return root !== null && root.children.length > 0; }, undefined, @@ -44,7 +49,7 @@ export async function waitForPulseReady(page: Page, timeoutMs = 120_000) { let lastError: unknown = null; while (Date.now() - startedAt < timeoutMs) { try { - const res = await page.request.get('/api/health'); + const res = await page.request.get("/api/health"); if (res.ok()) { return; } @@ -54,7 +59,7 @@ export async function waitForPulseReady(page: Page, timeoutMs = 120_000) { } await page.waitForTimeout(1000); } - throw lastError ?? new Error('Timed out waiting for Pulse to become ready'); + throw lastError ?? new Error("Timed out waiting for Pulse to become ready"); } type SecurityStatus = { @@ -65,22 +70,22 @@ type ResetFirstRunResponse = { bootstrapToken?: string; }; -type SetupCompletionTarget = 'install' | 'platforms' | 'none'; +type SetupCompletionTarget = "install" | "platforms" | "none"; type CompleteSetupWizardOptions = { completionTarget?: SetupCompletionTarget; }; const SETUP_COMPLETION_HANDOFFS: Record< - Exclude, + Exclude, { buttonName: string; urlPattern: RegExp } > = { install: { - buttonName: 'Open Infrastructure Install', + buttonName: "Open Infrastructure Install", urlPattern: /\/settings\/infrastructure\/install/, }, platforms: { - buttonName: 'Open Platform connections', + buttonName: "Open Platform connections", urlPattern: /\/settings\/infrastructure\/platforms/, }, }; @@ -90,92 +95,128 @@ async function completeSetupWizard( bootstrapToken: string, options: CompleteSetupWizardOptions = {}, ) { - const completionTarget = options.completionTarget ?? 'install'; + const completionTarget = options.completionTarget ?? "install"; if (!bootstrapToken) { - throw new Error('Pulse requires first-run setup but no bootstrap token is available'); + throw new Error( + "Pulse requires first-run setup but no bootstrap token is available", + ); } - await page.goto('/'); + await page.goto("/"); await waitForAppShell(page); - const wizard = page.getByRole('main', { name: 'Pulse Setup Wizard' }); + const wizard = page.getByRole("main", { name: "Pulse Setup Wizard" }); await expect(wizard).toBeVisible(); - const completionHeading = wizard.getByRole('heading', { + const completionHeading = wizard.getByRole("heading", { name: /install your first monitored host|first monitored host connected/i, }); - const openInstallWorkspaceButton = wizard.getByRole('button', { - name: 'Open Infrastructure Install', + const openInstallWorkspaceButton = wizard.getByRole("button", { + name: "Open Infrastructure Install", exact: true, }); - const openPlatformConnectionsButton = wizard.getByRole('button', { - name: 'Open Platform connections', + const openPlatformConnectionsButton = wizard.getByRole("button", { + name: "Open Platform connections", exact: true, }); - const secureDashboardHeading = wizard.getByText('Secure Your Dashboard'); - const continueButton = wizard.getByRole('button', { + const secureDashboardHeading = wizard.getByText("Secure Your Dashboard"); + const continueButton = wizard.getByRole("button", { name: /verify bootstrap token|continue to setup|continue/i, }); - const finishButton = wizard.getByRole('button', { name: /go to dashboard|skip for now/i }); - const bootstrapTokenInput = page.getByPlaceholder('Paste your bootstrap token'); + const finishButton = wizard.getByRole("button", { + name: /go to dashboard|skip for now/i, + }); + const bootstrapTokenInput = page.getByPlaceholder( + "Paste your bootstrap token", + ); await bootstrapTokenInput.click(); - await bootstrapTokenInput.fill(''); + await bootstrapTokenInput.fill(""); await bootstrapTokenInput.pressSequentially(bootstrapToken, { delay: 10 }); - const detectWizardStep = async (): Promise<'security' | 'completion' | 'pending'> => { + const detectWizardStep = async (): Promise< + "security" | "completion" | "pending" + > => { if ( - await completionHeading.isVisible({ timeout: 100 }).catch(() => false) || - await openInstallWorkspaceButton.isVisible({ timeout: 100 }).catch(() => false) || - await openPlatformConnectionsButton.isVisible({ timeout: 100 }).catch(() => false) + (await completionHeading + .isVisible({ timeout: 100 }) + .catch(() => false)) || + (await openInstallWorkspaceButton + .isVisible({ timeout: 100 }) + .catch(() => false)) || + (await openPlatformConnectionsButton + .isVisible({ timeout: 100 }) + .catch(() => false)) ) { - return 'completion'; + return "completion"; } - if (await secureDashboardHeading.isVisible({ timeout: 100 }).catch(() => false)) { - return 'security'; + if ( + await secureDashboardHeading + .isVisible({ timeout: 100 }) + .catch(() => false) + ) { + return "security"; } - return 'pending'; + return "pending"; }; // The welcome step now prefers auto-submit once the pasted bootstrap token // is long enough. Only fall back to the explicit verify button if the step // stays put after that auto-submit window. let wizardStep = await detectWizardStep(); - if (wizardStep === 'pending') { - await expect.poll(detectWizardStep, { timeout: 10_000 }).not.toBe('pending').catch(() => {}); + if (wizardStep === "pending") { + await expect + .poll(detectWizardStep, { timeout: 10_000 }) + .not.toBe("pending") + .catch(() => {}); wizardStep = await detectWizardStep(); } if ( - wizardStep === 'pending' && - await continueButton.isVisible({ timeout: 250 }).catch(() => false) && - await continueButton.isEnabled().catch(() => false) + wizardStep === "pending" && + (await continueButton.isVisible({ timeout: 250 }).catch(() => false)) && + (await continueButton.isEnabled().catch(() => false)) ) { await continueButton.click({ timeout: 1_000 }).catch(() => {}); - await expect.poll(detectWizardStep, { timeout: 10_000 }).not.toBe('pending'); + await expect + .poll(detectWizardStep, { timeout: 10_000 }) + .not.toBe("pending"); wizardStep = await detectWizardStep(); } - const onSecurityStep = wizardStep === 'security'; - let onCompleteStep = wizardStep === 'completion'; + const onSecurityStep = wizardStep === "security"; + let onCompleteStep = wizardStep === "completion"; if (!onSecurityStep && !onCompleteStep) { - throw new Error('Setup wizard did not reach security or completion step'); + throw new Error("Setup wizard did not reach security or completion step"); } if (onSecurityStep) { - const customPasswordButton = wizard.getByRole('button', { name: /custom password/i }); - if (await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false)) { + const customPasswordButton = wizard.getByRole("button", { + name: /custom password/i, + }); + if ( + await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false) + ) { let clickedCustomPassword = false; for (let attempt = 0; attempt < 3; attempt++) { try { - await customPasswordButton.click({ timeout: 10_000, force: attempt > 0 }); + await customPasswordButton.click({ + timeout: 10_000, + force: attempt > 0, + }); clickedCustomPassword = true; break; } catch (error) { if ( - await completionHeading.isVisible({ timeout: 250 }).catch(() => false) || - await openInstallWorkspaceButton.isVisible({ timeout: 250 }).catch(() => false) || - await openPlatformConnectionsButton.isVisible({ timeout: 250 }).catch(() => false) + (await completionHeading + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await openInstallWorkspaceButton + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await openPlatformConnectionsButton + .isVisible({ timeout: 250 }) + .catch(() => false)) ) { onCompleteStep = true; break; @@ -188,29 +229,44 @@ async function completeSetupWizard( } if (!onCompleteStep && clickedCustomPassword) { - await wizard.locator('input[type="text"]').first().fill(E2E_CREDENTIALS.username); - await wizard.locator('input[type="password"]').nth(0).fill(E2E_CREDENTIALS.password); - await wizard.locator('input[type="password"]').nth(1).fill(E2E_CREDENTIALS.password); + await wizard + .locator('input[type="text"]') + .first() + .fill(E2E_CREDENTIALS.username); + await wizard + .locator('input[type="password"]') + .nth(0) + .fill(E2E_CREDENTIALS.password); + await wizard + .locator('input[type="password"]') + .nth(1) + .fill(E2E_CREDENTIALS.password); - await wizard.getByRole('button', { name: /create account/i }).click(); + await wizard.getByRole("button", { name: /create account/i }).click(); await expect .poll(async () => { if ( - await completionHeading.isVisible({ timeout: 250 }).catch(() => false) || - await openInstallWorkspaceButton.isVisible({ timeout: 250 }).catch(() => false) || - await openPlatformConnectionsButton.isVisible({ timeout: 250 }).catch(() => false) + (await completionHeading + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await openInstallWorkspaceButton + .isVisible({ timeout: 250 }) + .catch(() => false)) || + (await openPlatformConnectionsButton + .isVisible({ timeout: 250 }) + .catch(() => false)) ) { - return 'complete'; + return "complete"; } if ( - completionTarget === 'install' && + completionTarget === "install" && SETUP_COMPLETION_HANDOFFS.install.urlPattern.test(page.url()) ) { - return 'handoff'; + return "handoff"; } - return 'pending'; + return "pending"; }) - .not.toBe('pending'); + .not.toBe("pending"); onCompleteStep = true; } } else { @@ -219,14 +275,16 @@ async function completeSetupWizard( } } - if (onCompleteStep && completionTarget !== 'none') { + if (onCompleteStep && completionTarget !== "none") { const completionAction = SETUP_COMPLETION_HANDOFFS[completionTarget]; if (!completionAction.urlPattern.test(page.url())) { - const completionButton = wizard.getByRole('button', { + const completionButton = wizard.getByRole("button", { name: completionAction.buttonName, exact: true, }); - const completionVisible = await completionButton.isVisible({ timeout: 500 }).catch(() => false); + const completionVisible = await completionButton + .isVisible({ timeout: 500 }) + .catch(() => false); if (completionVisible) { await completionButton.scrollIntoViewIfNeeded(); @@ -241,18 +299,20 @@ async function completeSetupWizard( ); } } else if (onCompleteStep) { - const finishVisible = await finishButton.isVisible({ timeout: 500 }).catch(() => false); + const finishVisible = await finishButton + .isVisible({ timeout: 500 }) + .catch(() => false); if (finishVisible) { await finishButton.scrollIntoViewIfNeeded(); await finishButton.click({ timeout: 10_000 }); } } - await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState("domcontentloaded"); } export async function getSecurityStatus(page: Page): Promise { - const res = await page.request.get('/api/security/status'); + const res = await page.request.get("/api/security/status"); if (!res.ok()) { throw new Error(`Failed to fetch security status: ${res.status()}`); } @@ -273,43 +333,53 @@ export async function ensureFirstRunExperience( options: CompleteSetupWizardOptions = {}, ) { await page.addInitScript(() => { - localStorage.setItem('pulse_whats_new_v2_shown', 'true'); + localStorage.setItem("pulse_whats_new_v2_shown", "true"); }); await waitForPulseReady(page); - const completionTarget = options.completionTarget ?? 'install'; + const completionTarget = options.completionTarget ?? "install"; let bootstrapToken = E2E_CREDENTIALS.bootstrapToken; const security = await getSecurityStatus(page); - let resetRes = await apiRequest(page, '/api/security/dev/reset-first-run', { - method: 'POST', + let resetRes = await apiRequest(page, "/api/security/dev/reset-first-run", { + method: "POST", headers: E2E_CREDENTIALS.primaryApiToken - ? { 'X-API-Token': E2E_CREDENTIALS.primaryApiToken } + ? { "X-API-Token": E2E_CREDENTIALS.primaryApiToken } : undefined, }); - if (resetRes.status() === 401 && !E2E_CREDENTIALS.primaryApiToken && security.hasAuthentication !== false) { + if ( + resetRes.status() === 401 && + !E2E_CREDENTIALS.primaryApiToken && + security.hasAuthentication !== false + ) { await login(page); - resetRes = await apiRequest(page, '/api/security/dev/reset-first-run', { - method: 'POST', + resetRes = await apiRequest(page, "/api/security/dev/reset-first-run", { + method: "POST", }); } if (!resetRes.ok()) { - throw new Error(`Failed to reset first-run state: ${resetRes.status()} ${await resetRes.text()}`); + throw new Error( + `Failed to reset first-run state: ${resetRes.status()} ${await resetRes.text()}`, + ); } const payload = (await resetRes.json()) as ResetFirstRunResponse; - bootstrapToken = String(payload.bootstrapToken || '').trim(); - if (bootstrapToken === '') { - throw new Error('First-run reset response did not include a bootstrap token'); + bootstrapToken = String(payload.bootstrapToken || "").trim(); + if (bootstrapToken === "") { + throw new Error( + "First-run reset response did not include a bootstrap token", + ); } await completeSetupWizard(page, bootstrapToken, { completionTarget }); const firstRunLandingPattern = - completionTarget === 'platforms' + completionTarget === "platforms" ? SETUP_COMPLETION_HANDOFFS.platforms.urlPattern : /\/(settings\/infrastructure\/install|proxmox|dashboard|nodes|hosts|docker|infrastructure)/; if (!firstRunLandingPattern.test(page.url())) { - if (completionTarget === 'platforms') { - throw new Error(`First-run setup did not reach Platform connections: ${page.url()}`); + if (completionTarget === "platforms") { + throw new Error( + `First-run setup did not reach Platform connections: ${page.url()}`, + ); } await login(page); } @@ -320,8 +390,8 @@ export async function ensureFirstRunExperience( * Login as admin user */ export async function loginAsAdmin(page: Page) { - await page.goto('/'); - await page.waitForSelector('input[name="username"]', { state: 'visible' }); + await page.goto("/"); + await page.waitForSelector('input[name="username"]', { state: "visible" }); await page.fill('input[name="username"]', E2E_CREDENTIALS.username); await page.fill('input[name="password"]', E2E_CREDENTIALS.password); await page.click('button[type="submit"]'); @@ -331,66 +401,82 @@ export async function loginAsAdmin(page: Page) { } export async function login(page: Page, credentials = E2E_CREDENTIALS) { - await page.goto('/'); + await page.goto("/"); await waitForAppShell(page); - const authenticatedURL = /\/(proxmox|dashboard|nodes|hosts|docker|infrastructure)/; + const authenticatedURL = + /\/(proxmox|dashboard|nodes|hosts|docker|infrastructure)/; const usernameInput = page.locator('input[name="username"]'); const state = await Promise.race([ usernameInput - .waitFor({ state: 'visible', timeout: 15_000 }) - .then(() => 'login') + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => "login") .catch(() => undefined), page .waitForURL(authenticatedURL, { timeout: 15_000 }) - .then(() => 'authenticated') + .then(() => "authenticated") .catch(() => undefined), ]); - if (state === 'authenticated') { + if (state === "authenticated") { return; } - if (state !== 'login') { + if (state !== "login") { const url = page.url(); - const preview = ((await page.locator('body').textContent()) || '').replace(/\s+/g, ' ').slice(0, 200); - throw new Error(`Login did not render and did not redirect (url=${url}, body="${preview}")`); + const preview = ((await page.locator("body").textContent()) || "") + .replace(/\s+/g, " ") + .slice(0, 200); + throw new Error( + `Login did not render and did not redirect (url=${url}, body="${preview}")`, + ); } - const loginErrorText = page.locator( - 'text=/Invalid username or password|Too many requests|Account locked|Failed to connect to server|Server error/i', - ).first(); + const loginErrorText = page + .locator( + "text=/Invalid username or password|Too many requests|Account locked|Failed to connect to server|Server error/i", + ) + .first(); await page.fill('input[name="username"]', credentials.username); await page.fill('input[name="password"]', credentials.password); await page.click('button[type="submit"]'); - await expect.poll( - async () => { - const url = page.url(); - if (authenticatedURL.test(url)) { - return 'authenticated'; - } + await expect + .poll( + async () => { + const url = page.url(); + if (authenticatedURL.test(url)) { + return "authenticated"; + } - const loginErrorVisible = await loginErrorText.isVisible().catch(() => false); - if (loginErrorVisible) { - const message = ((await loginErrorText.textContent()) || 'login_error').trim(); - return `error:${message}`; - } + const loginErrorVisible = await loginErrorText + .isVisible() + .catch(() => false); + if (loginErrorVisible) { + const message = ( + (await loginErrorText.textContent()) || "login_error" + ).trim(); + return `error:${message}`; + } - const stillShowingLogin = await usernameInput.isVisible().catch(() => false); - if (stillShowingLogin) { - return 'login'; - } + const stillShowingLogin = await usernameInput + .isVisible() + .catch(() => false); + if (stillShowingLogin) { + return "login"; + } - return 'pending'; - }, - { - timeout: 30_000, - message: 'Timed out waiting for authenticated app state after login submission', - }, - ).toBe('authenticated'); + return "pending"; + }, + { + timeout: 30_000, + message: + "Timed out waiting for authenticated app state after login submission", + }, + ) + .toBe("authenticated"); } /** @@ -400,7 +486,7 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) { */ export async function dismissWhatsNewModal(page: Page): Promise { await page.evaluate(() => { - localStorage.setItem('pulse_whats_new_v2_shown', 'true'); + localStorage.setItem("pulse_whats_new_v2_shown", "true"); }); } @@ -409,12 +495,14 @@ export async function ensureAuthenticated(page: Page) { // any page script on every navigation. This prevents the "fixed inset-0 z-50" // overlay from appearing and blocking clicks (logout, row taps, etc.) in tests. await page.addInitScript(() => { - localStorage.setItem('pulse_whats_new_v2_shown', 'true'); + localStorage.setItem("pulse_whats_new_v2_shown", "true"); }); await waitForPulseReady(page); await maybeCompleteSetupWizard(page); await login(page); - await expect(page).toHaveURL(/\/(proxmox|dashboard|nodes|hosts|docker|infrastructure)/); + await expect(page).toHaveURL( + /\/(proxmox|dashboard|nodes|hosts|docker|infrastructure)/, + ); } export async function createAuthenticatedStorageState( @@ -442,11 +530,13 @@ export async function logout(page: Page) { } export async function setMockMode(page: Page, enabled: boolean) { - const send = () => apiRequest(page, '/api/system/mock-mode', { - method: 'POST', - data: { enabled }, - headers: { 'Content-Type': 'application/json' }, - }); + await waitForPulseReady(page); + const send = () => + apiRequest(page, "/api/system/mock-mode", { + method: "POST", + data: { enabled }, + headers: { "Content-Type": "application/json" }, + }); // Mock mode toggle can fail transiently when the backend is still // processing a previous toggle (e.g. between consecutive suite runs). @@ -475,29 +565,48 @@ export async function setMockMode(page: Page, enabled: boolean) { } } - throw new Error(`Failed to update mock mode after 3 attempts: ${lastError?.message}`); + throw new Error( + `Failed to update mock mode after 3 attempts: ${lastError?.message}`, + ); } export async function getMockMode(page: Page) { - const send = () => apiRequest(page, '/api/system/mock-mode'); + await waitForPulseReady(page); + const send = () => apiRequest(page, "/api/system/mock-mode"); + let lastError: Error | null = null; - let res = await send(); - if (res.status() === 401) { - await login(page); - res = await send(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + let res = await send(); + if (res.status() === 401) { + await login(page); + res = await send(); + } + + if (res.ok()) { + return (await res.json()) as { enabled: boolean }; + } + + lastError = new Error(`HTTP ${res.status()}: ${await res.text()}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + + if (attempt < 2) { + await page.waitForTimeout(2_000); + } } - if (!res.ok()) { - throw new Error(`Failed to read mock mode: ${res.status()} ${await res.text()}`); - } - return (await res.json()) as { enabled: boolean }; + throw new Error( + `Failed to read mock mode after 3 attempts: ${lastError?.message}`, + ); } /** * Navigate to settings page */ export async function navigateToSettings(page: Page) { - await page.goto('/settings'); + await page.goto("/settings"); // Wait for the settings route to load. The desktop sidebar (aria-label="Settings navigation") // is hidden on mobile viewports (lg:flex), so we wait for the URL instead of sidebar visibility. @@ -508,7 +617,9 @@ export async function navigateToSettings(page: Page) { * Wait for update banner to appear */ export async function waitForUpdateBanner(page: Page, timeout = 30000) { - const banner = page.locator('[data-testid="update-banner"], .update-banner').first(); + const banner = page + .locator('[data-testid="update-banner"], .update-banner') + .first(); await expect(banner).toBeVisible({ timeout }); return banner; } @@ -517,7 +628,10 @@ export async function waitForUpdateBanner(page: Page, timeout = 30000) { * Click "Apply Update" button in update banner */ export async function clickApplyUpdate(page: Page) { - const applyButton = page.locator('button').filter({ hasText: /apply update/i }).first(); + const applyButton = page + .locator("button") + .filter({ hasText: /apply update/i }) + .first(); await expect(applyButton).toBeVisible(); await applyButton.click(); } @@ -526,7 +640,10 @@ export async function clickApplyUpdate(page: Page) { * Wait for update confirmation modal */ export async function waitForConfirmationModal(page: Page) { - const modal = page.locator('[role="dialog"], .modal').filter({ hasText: /confirm/i }).first(); + const modal = page + .locator('[role="dialog"], .modal') + .filter({ hasText: /confirm/i }) + .first(); await expect(modal).toBeVisible({ timeout: 10000 }); return modal; } @@ -542,7 +659,10 @@ export async function confirmUpdate(page: Page) { } // Click confirm button - const confirmButton = page.locator('button').filter({ hasText: /confirm|proceed|continue/i }).first(); + const confirmButton = page + .locator("button") + .filter({ hasText: /confirm|proceed|continue/i }) + .first(); await confirmButton.click(); } @@ -550,7 +670,8 @@ export async function confirmUpdate(page: Page) { * Wait for update progress modal */ export async function waitForProgressModal(page: Page) { - const modal = page.locator('[data-testid="update-progress-modal"], [role="dialog"]') + const modal = page + .locator('[data-testid="update-progress-modal"], [role="dialog"]') .filter({ hasText: /updating|progress|downloading/i }) .first(); await expect(modal).toBeVisible({ timeout: 10000 }); @@ -561,7 +682,9 @@ export async function waitForProgressModal(page: Page) { * Count visible modals on page */ export async function countVisibleModals(page: Page): Promise { - const modals = page.locator('[role="dialog"], .modal').filter({ hasText: /update|progress/i }); + const modals = page + .locator('[role="dialog"], .modal') + .filter({ hasText: /update|progress/i }); return await modals.count(); } @@ -569,7 +692,7 @@ export async function countVisibleModals(page: Page): Promise { * Wait for error message in modal */ export async function waitForErrorInModal(page: Page, modal: any) { - const errorText = modal.locator('text=/error|failed|invalid/i').first(); + const errorText = modal.locator("text=/error|failed|invalid/i").first(); await expect(errorText).toBeVisible({ timeout: 30000 }); return errorText; } @@ -592,20 +715,27 @@ export async function assertUserFriendlyError(errorText: string) { */ export async function dismissModal(page: Page) { // Try close button first - const closeButton = page.locator('button[aria-label="Close"], button.close, button').filter({ hasText: /close|dismiss/i }).first(); + const closeButton = page + .locator('button[aria-label="Close"], button.close, button') + .filter({ hasText: /close|dismiss/i }) + .first(); if (await closeButton.isVisible({ timeout: 2000 }).catch(() => false)) { await closeButton.click(); return; } // Try ESC key - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); } /** * Wait for progress to reach a certain percentage */ -export async function waitForProgress(page: Page, modal: any, minPercent: number) { +export async function waitForProgress( + page: Page, + modal: any, + minPercent: number, +) { await page.waitForFunction( ({ modalSelector, min }) => { const modal = document.querySelector(modalSelector); @@ -614,17 +744,17 @@ export async function waitForProgress(page: Page, modal: any, minPercent: number // Check for progress bar or percentage text const progressBar = modal.querySelector('[role="progressbar"]'); if (progressBar) { - const value = progressBar.getAttribute('aria-valuenow'); + const value = progressBar.getAttribute("aria-valuenow"); return value && parseInt(value) >= min; } // Check for percentage text - const text = modal.textContent || ''; + const text = modal.textContent || ""; const match = text.match(/(\d+)%/); return match && parseInt(match[1]) >= min; }, { modalSelector: '[role="dialog"]', min: minPercent }, - { timeout: 30000 } + { timeout: 30000 }, ); } @@ -639,7 +769,7 @@ export async function restartWithMockConfig(config: { }) { // This would be implemented by CI/test runner to restart containers // with new environment variables - console.log('Mock config:', config); + console.log("Mock config:", config); } /** @@ -654,23 +784,31 @@ export async function resetTestEnvironment() { /** * Make API request to Pulse backend */ -export async function apiRequest(page: Page, endpoint: string, options: any = {}) { - const baseURL = preferredBrowserBaseURL().replace(/\/+$/, ''); +export async function apiRequest( + page: Page, + endpoint: string, + options: any = {}, +) { + const baseURL = preferredBrowserBaseURL().replace(/\/+$/, ""); - const method = String(options.method || 'GET').toUpperCase(); + const method = String(options.method || "GET").toUpperCase(); const headers = { ...(options.headers || {}) } as Record; const hasNonSessionAuth = - typeof headers.Authorization === 'string' && - /^(basic|bearer)\s+/i.test(headers.Authorization) || - typeof headers['X-API-Token'] === 'string'; + (typeof headers.Authorization === "string" && + /^(basic|bearer)\s+/i.test(headers.Authorization)) || + typeof headers["X-API-Token"] === "string"; - if (!hasNonSessionAuth && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { - const hasCSRFHeader = Object.keys(headers).some((name) => name.toLowerCase() === 'x-csrf-token'); + if (!hasNonSessionAuth && !["GET", "HEAD", "OPTIONS"].includes(method)) { + const hasCSRFHeader = Object.keys(headers).some( + (name) => name.toLowerCase() === "x-csrf-token", + ); if (!hasCSRFHeader) { const cookies = await page.context().cookies(baseURL); - const csrfCookie = cookies.find((cookie) => cookie.name === 'pulse_csrf')?.value; + const csrfCookie = cookies.find( + (cookie) => cookie.name === "pulse_csrf", + )?.value; if (csrfCookie) { - headers['X-CSRF-Token'] = csrfCookie; + headers["X-CSRF-Token"] = csrfCookie; } } } @@ -683,31 +821,38 @@ export async function apiRequest(page: Page, endpoint: string, options: any = {} } export async function isMultiTenantEnabled(page: Page): Promise { - const orgsRes = await apiRequest(page, '/api/orgs'); + const orgsRes = await apiRequest(page, "/api/orgs"); return orgsRes.ok(); } const toOrgID = (displayName: string) => { - const base = displayName - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 36) || 'org'; + const base = + displayName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 36) || "org"; const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; return `${base}-${suffix}`.slice(0, 64); }; -export async function createOrg(page: Page, displayName: string): Promise<{ id: string }> { - const res = await apiRequest(page, '/api/orgs', { - method: 'POST', +export async function createOrg( + page: Page, + displayName: string, +): Promise<{ id: string }> { + const res = await apiRequest(page, "/api/orgs", { + method: "POST", data: { id: toOrgID(displayName), displayName }, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, }); - if (!res.ok()) throw new Error(`Failed to create org: ${res.status()} ${await res.text()}`); + if (!res.ok()) + throw new Error( + `Failed to create org: ${res.status()} ${await res.text()}`, + ); const payload = (await res.json()) as { id?: string }; if (!payload.id) { - throw new Error('Failed to create org: response missing org id'); + throw new Error("Failed to create org: response missing org id"); } return { id: payload.id }; @@ -715,28 +860,36 @@ export async function createOrg(page: Page, displayName: string): Promise<{ id: export async function deleteOrg(page: Page, orgId: string): Promise { const res = await apiRequest(page, `/api/orgs/${encodeURIComponent(orgId)}`, { - method: 'DELETE', + method: "DELETE", }); if (!res.ok() && res.status() !== 404) { - throw new Error(`Failed to delete org: ${res.status()} ${await res.text()}`); + throw new Error( + `Failed to delete org: ${res.status()} ${await res.text()}`, + ); } } export async function switchOrg(page: Page, orgId: string): Promise { await page.evaluate((id) => { - window.sessionStorage.setItem('pulse_org_id', id); - window.localStorage.setItem('pulse_org_id', id); + window.sessionStorage.setItem("pulse_org_id", id); + window.localStorage.setItem("pulse_org_id", id); document.cookie = `pulse_org_id=${encodeURIComponent(id)}; Path=/; SameSite=Lax`; }, orgId); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState("networkidle"); } /** * Check for updates via API */ -export async function checkForUpdatesAPI(page: Page, channel: 'stable' | 'rc' = 'stable') { - const response = await apiRequest(page, `/api/updates/check?channel=${channel}`); +export async function checkForUpdatesAPI( + page: Page, + channel: "stable" | "rc" = "stable", +) { + const response = await apiRequest( + page, + `/api/updates/check?channel=${channel}`, + ); return response.json(); } @@ -744,9 +897,9 @@ export async function checkForUpdatesAPI(page: Page, channel: 'stable' | 'rc' = * Apply update via API */ export async function applyUpdateAPI(page: Page, downloadUrl: string) { - const response = await apiRequest(page, '/api/updates/apply', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await apiRequest(page, "/api/updates/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, data: { url: downloadUrl }, }); return response; @@ -756,7 +909,7 @@ export async function applyUpdateAPI(page: Page, downloadUrl: string) { * Get update status via API */ export async function getUpdateStatusAPI(page: Page) { - const response = await apiRequest(page, '/api/updates/status'); + const response = await apiRequest(page, "/api/updates/status"); return response.json(); } @@ -785,7 +938,7 @@ export class Timer { export async function pollUntil( fn: () => Promise, condition: (result: T) => boolean, - options: { timeout?: number; interval?: number } = {} + options: { timeout?: number; interval?: number } = {}, ): Promise { const timeout = options.timeout || 30000; const interval = options.interval || 1000; @@ -796,7 +949,7 @@ export async function pollUntil( if (condition(result)) { return result; } - await new Promise(resolve => setTimeout(resolve, interval)); + await new Promise((resolve) => setTimeout(resolve, interval)); } throw new Error(`Polling timed out after ${timeout}ms`);