fix(frontend): sync summary hover cursor and proof

This commit is contained in:
rcourtman 2026-04-01 14:48:47 +01:00
parent 76ba35a3a9
commit cabd224987
21 changed files with 773 additions and 257 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -112,6 +112,7 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (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<InfrastructureSummaryProps> = (pro
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
formatValue={formatThroughputRate}
focusEmptyStateLabel="No network activity in range"
hoverSourceKey="network"
hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()}

View file

@ -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({

View file

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

View file

@ -811,6 +811,7 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (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<WorkloadsSummaryProps> = (props) => {
rangeLabel={selectedRange()}
timeRange={selectedRange()}
formatValue={formatThroughputRate}
focusEmptyStateLabel="No network activity in range"
hoverSourceKey="network"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}

View file

@ -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<DensityMapProps> = (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<DensityMapProps> = (props) => {
<div
class="flex w-full min-w-0 items-center gap-2 text-[10px]"
data-density-map-focus-detail="true"
data-density-map-focus-empty={hasDensityMapFocusActivity(detail()) ? 'false' : 'true'}
data-density-map-focus-series-id={detail().seriesId}
>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
@ -64,24 +70,33 @@ export const DensityMap: Component<DensityMapProps> = (props) => {
)}
</Show>
</div>
<div class="flex shrink-0 items-center gap-3 leading-none">
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Latest
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().currentValue)}
</span>
<Show
when={hasDensityMapFocusActivity(detail())}
fallback={
<div class="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[9px] font-medium uppercase tracking-[0.08em] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
{props.focusEmptyStateLabel ?? 'No activity in range'}
</div>
}
>
<div class="flex shrink-0 items-center gap-3 leading-none">
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Latest
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().currentValue)}
</span>
</div>
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Peak
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().peakValue)}
</span>
</div>
</div>
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Peak
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().peakValue)}
</span>
</div>
</div>
</Show>
</div>
)}
</Show>

View file

@ -34,6 +34,7 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (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<InteractiveSparklineProps> = (props
)}
</For>
<Show when={sparkline.activeHoverState()}>
{(hover) => (
<Show when={sparkline.activeHoverCursorX() !== null}>
{(cursorX) => (
<line
x1={hover().x}
y1={Math.max(0, hover().minY - 4)}
x2={hover().x}
x1={cursorX()}
y1={Math.max(0, (sparkline.activeHoverState()?.minY ?? 4) - 4)}
x2={cursorX()}
y2={sparkline.vbH}
stroke="currentColor"
stroke-opacity="0.45"

View file

@ -558,6 +558,7 @@ describe('shared primitive guardrails', () => {
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');
});

View file

@ -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(() => (
<DensityMap
timeRange="1h"
highlightSeriesId="alpha"
focusEmptyStateLabel="No disk activity in range"
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [],
},
{
id: 'beta',
name: 'Beta',
color: '#3b82f6',
data: [
{ timestamp: now - 30_000, value: 18 },
{ timestamp: now - 5_000, value: 24 },
],
},
]}
/>
));
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();
});
});

View file

@ -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(() => (
<InteractiveSparkline
timeRange="1h"
hoverSourceKey="memory"
hoverSync={{
sourceKey: 'cpu',
seriesId: 'missing-series',
timestamp: now - 10_000,
}}
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#ff0000',
data: [
{ timestamp: now - 30_000, value: 40 },
{ timestamp: now - 10_000, value: 50 },
],
},
]}
/>
));
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'));

View file

@ -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;

View file

@ -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,

View file

@ -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<number | null>(() => {
const hoverSync = props.hoverSync;
if (!hoverSync) {
return null;
}
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
return null;
}
return hoverSync.timestamp;
});
const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
return hoveredState() ?? synchronizedHoveredState();
});
const activeHoverTimestamp = createMemo<number | null>(() => {
return hoveredState()?.timestamp ?? synchronizedHoverTimestamp();
});
const activeColumnIndex = createMemo<number | null>(() =>
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,

View file

@ -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<number | null>(() => {
const hoverSync = props.hoverSync;
if (!hoverSync) {
return null;
}
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
return null;
}
return hoverSync.timestamp;
});
const activeHoverState = createMemo<InteractiveSparklineHoverState | null>(() => {
return hoveredState() ?? synchronizedHoverState();
});
const activeHoverTimestamp = createMemo<number | null>(() => {
return hoveredState()?.timestamp ?? synchronizedHoverTimestamp();
});
const activeHoverCursorX = createMemo<number | null>(() =>
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,

View file

@ -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/**"],
},
],

View file

@ -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

View file

@ -248,6 +248,28 @@ async function expectActiveDensityMapsPreserveOverview(
});
}
async function expectSummaryHoverTimestampsAligned(
summary: import("@playwright/test").Locator,
expectedCount: number,
): Promise<void> {
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);
});
});

View file

@ -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<SetupCompletionTarget, 'none'>,
Exclude<SetupCompletionTarget, "none">,
{ 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<SecurityStatus> {
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<void> {
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<number> {
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<number> {
* 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<string, string>;
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<boolean> {
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<void> {
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<void> {
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<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
options: { timeout?: number; interval?: number } = {}
options: { timeout?: number; interval?: number } = {},
): Promise<T> {
const timeout = options.timeout || 30000;
const interval = options.interval || 1000;
@ -796,7 +949,7 @@ export async function pollUntil<T>(
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`);