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 canonical dev reset route, and any helper changes that rely on hot-dev
browser/backend behavior must keep a managed-runtime recovery proof updated browser/backend behavior must keep a managed-runtime recovery proof updated
in the same slice. 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 ## 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 `frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw
route strings or provider-local link builders inside feature panels. 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. 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. 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 18. Keep shared infrastructure/resource selectors on the canonical agent-facet
truth. Shared primitives and settings-facing selector helpers must treat 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 page-local “selected row” overlays on top of already downsampled summary
history. Hovering a sparkline or density map for one entity must promote that 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 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 For shared line charts on that hot path, the shared sparkline primitive may
isolate the selected series inside the existing render budget, but that isolate the selected series inside the existing render budget, but that
isolation must still reuse the same summary series set and timeline data rather 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` 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 rendering the full page-level series set while only the focused label and
highlight state change. 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 ## Current State

View file

@ -112,6 +112,7 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
rangeLabel={rangeLabel()} rangeLabel={rangeLabel()}
timeRange={props.timeRange} timeRange={props.timeRange}
formatValue={formatThroughputRate} formatValue={formatThroughputRate}
focusEmptyStateLabel="No disk activity in range"
hoverSourceKey="diskio" hoverSourceKey="diskio"
hoverSync={state.chartHoverSync()} hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()} highlightSeriesId={state.activeSeriesId()}
@ -187,6 +188,7 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
rangeLabel={rangeLabel()} rangeLabel={rangeLabel()}
timeRange={props.timeRange} timeRange={props.timeRange}
formatValue={formatThroughputRate} formatValue={formatThroughputRate}
focusEmptyStateLabel="No network activity in range"
hoverSourceKey="network" hoverSourceKey="network"
hoverSync={state.chartHoverSync()} hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()} 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', () => { it('uses one canonical active series id across hover and focused summary selection', () => {
expect( expect(
resolveSummaryActiveSeriesId({ resolveSummaryActiveSeriesId({

View file

@ -27,12 +27,14 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({
interactionState?: string; interactionState?: string;
activeSeriesDisplay?: string; activeSeriesDisplay?: string;
hoverSourceKey?: string; hoverSourceKey?: string;
hoverSync?: { seriesId: string } | null; hoverSync?: { seriesId: string; timestamp?: number } | null;
onHoverSyncChange?: (value: { onHoverSyncChange?: (
sourceKey: string; value: {
seriesId: string; sourceKey: string;
timestamp: number; seriesId: string;
} | null) => void; timestamp: number;
} | null,
) => void;
}) => { }) => {
const series = props.series ?? []; const series = props.series ?? [];
const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0); 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-highlight-series-id={props.highlightSeriesId || ''}
data-hover-source-key={props.hoverSourceKey || ''} data-hover-source-key={props.hoverSourceKey || ''}
data-hover-sync-series-id={props.hoverSync?.seriesId || ''} 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-interaction-state={props.interactionState || 'default'}
data-active-series-display={props.activeSeriesDisplay || ''} data-active-series-display={props.activeSeriesDisplay || ''}
/> />
@ -77,12 +82,14 @@ vi.mock('@/components/shared/DensityMap', () => ({
highlightSeriesId?: string | null; highlightSeriesId?: string | null;
interactionState?: string; interactionState?: string;
hoverSourceKey?: string; hoverSourceKey?: string;
hoverSync?: { seriesId: string } | null; hoverSync?: { seriesId: string; timestamp?: number } | null;
onHoverSyncChange?: (value: { onHoverSyncChange?: (
sourceKey: string; value: {
seriesId: string; sourceKey: string;
timestamp: number; seriesId: string;
} | null) => void; timestamp: number;
} | null,
) => void;
}) => { }) => {
const series = props.series ?? []; const series = props.series ?? [];
const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0); 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-highlight-series-id={props.highlightSeriesId || ''}
data-hover-source-key={props.hoverSourceKey || ''} data-hover-source-key={props.hoverSourceKey || ''}
data-hover-sync-series-id={props.hoverSync?.seriesId || ''} 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-interaction-state={props.interactionState || 'default'}
/> />
</> </>
@ -389,9 +399,14 @@ describe('WorkloadsSummary performance behavior', () => {
await waitFor(() => { await waitFor(() => {
const sparklines = screen.getAllByTestId('sparkline'); const sparklines = screen.getAllByTestId('sparkline');
expect(sparklines).toHaveLength(4); 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) { for (const sparkline of sparklines) {
expect(sparkline.getAttribute('data-highlight-series-id')).toBe(workloadIds[0]); 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-series-id')).toBe(workloadIds[0]);
expect(sparkline.getAttribute('data-hover-sync-timestamp')).not.toBe('');
expect(sparkline.getAttribute('data-interaction-state')).toBe('active'); expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
} }
}); });

View file

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

View file

@ -1,6 +1,10 @@
import { Component, Show } from 'solid-js'; import { Component, Show } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { formatDensityMapHoverTime, type DensityMapProps } from './densityMapModel'; import {
formatDensityMapHoverTime,
hasDensityMapFocusActivity,
type DensityMapProps,
} from './densityMapModel';
import { useDensityMapState } from './useDensityMapState'; import { useDensityMapState } from './useDensityMapState';
export type { DensityMapProps } from './densityMapModel'; export type { DensityMapProps } from './densityMapModel';
@ -17,6 +21,7 @@ export const DensityMap: Component<DensityMapProps> = (props) => {
interactionState() === 'inactive' ? 'opacity-35' : 'opacity-100' interactionState() === 'inactive' ? 'opacity-35' : 'opacity-100'
}`.trim()} }`.trim()}
data-summary-chart-kind="density-map" data-summary-chart-kind="density-map"
data-active-hover-timestamp={densityMap.activeHoverTimestamp() ?? ''}
data-highlight-series-id={props.highlightSeriesId ?? ''} data-highlight-series-id={props.highlightSeriesId ?? ''}
data-highlight-series-active={densityMap.externalSeriesIndex() !== null ? 'true' : 'false'} data-highlight-series-active={densityMap.externalSeriesIndex() !== null ? 'true' : 'false'}
data-rendered-series-count={densityMap.chartData().series.length} data-rendered-series-count={densityMap.chartData().series.length}
@ -35,6 +40,7 @@ export const DensityMap: Component<DensityMapProps> = (props) => {
<div <div
class="flex w-full min-w-0 items-center gap-2 text-[10px]" class="flex w-full min-w-0 items-center gap-2 text-[10px]"
data-density-map-focus-detail="true" data-density-map-focus-detail="true"
data-density-map-focus-empty={hasDensityMapFocusActivity(detail()) ? 'false' : 'true'}
data-density-map-focus-series-id={detail().seriesId} data-density-map-focus-series-id={detail().seriesId}
> >
<div class="flex min-w-0 flex-1 items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-1.5">
@ -64,24 +70,33 @@ export const DensityMap: Component<DensityMapProps> = (props) => {
)} )}
</Show> </Show>
</div> </div>
<div class="flex shrink-0 items-center gap-3 leading-none"> <Show
<div class="flex flex-col items-start"> when={hasDensityMapFocusActivity(detail())}
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400"> fallback={
Latest <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">
</span> {props.focusEmptyStateLabel ?? 'No activity in range'}
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50"> </div>
{formatDetailValue(detail().currentValue)} }
</span> >
<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>
<div class="flex flex-col items-start"> </Show>
<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> </div>
)} )}
</Show> </Show>

View file

@ -34,6 +34,7 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
}`.trim()} }`.trim()}
data-highlight-series-id={props.highlightSeriesId ?? ''} data-highlight-series-id={props.highlightSeriesId ?? ''}
data-highlight-series-active={sparkline.externalSeriesIndex() !== null ? 'true' : 'false'} data-highlight-series-active={sparkline.externalSeriesIndex() !== null ? 'true' : 'false'}
data-active-hover-timestamp={sparkline.activeHoverTimestamp() ?? ''}
data-active-series-display={sparkline.activeSeriesDisplay()} data-active-series-display={sparkline.activeSeriesDisplay()}
data-rendered-series-count={sparkline.renderedSeriesCount()} data-rendered-series-count={sparkline.renderedSeriesCount()}
data-summary-chart-state={interactionState()} data-summary-chart-state={interactionState()}
@ -118,12 +119,12 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
)} )}
</For> </For>
<Show when={sparkline.activeHoverState()}> <Show when={sparkline.activeHoverCursorX() !== null}>
{(hover) => ( {(cursorX) => (
<line <line
x1={hover().x} x1={cursorX()}
y1={Math.max(0, hover().minY - 4)} y1={Math.max(0, (sparkline.activeHoverState()?.minY ?? 4) - 4)}
x2={hover().x} x2={cursorX()}
y2={sparkline.vbH} y2={sparkline.vbH}
stroke="currentColor" stroke="currentColor"
stroke-opacity="0.45" stroke-opacity="0.45"

View file

@ -558,6 +558,7 @@ describe('shared primitive guardrails', () => {
expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData'); expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData');
expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState'); expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState');
expect(interactiveSparklineModelSource).toContain('getInteractiveSparklineCursorXForTimestamp');
expect(interactiveSparklineModelSource).toContain('downsampleLTTB'); expect(interactiveSparklineModelSource).toContain('downsampleLTTB');
expect(interactiveSparklineModelSource).toContain('findNearestMetricPoint'); expect(interactiveSparklineModelSource).toContain('findNearestMetricPoint');
}); });
@ -577,6 +578,7 @@ describe('shared primitive guardrails', () => {
expect(densityMapModelSource).toContain('buildDensityMapFocusDetail'); expect(densityMapModelSource).toContain('buildDensityMapFocusDetail');
expect(densityMapModelSource).toContain('buildDensityMapHoveredState'); expect(densityMapModelSource).toContain('buildDensityMapHoveredState');
expect(densityMapModelSource).toContain('formatDensityMapHoverTime'); expect(densityMapModelSource).toContain('formatDensityMapHoverTime');
expect(densityMapModelSource).toContain('getDensityMapColumnIndexForTimestamp');
expect(densityMapModelSource).toContain('getDensityMapCellOpacity'); expect(densityMapModelSource).toContain('getDensityMapCellOpacity');
}); });

View file

@ -244,6 +244,7 @@ describe('DensityMap', () => {
const root = container.firstElementChild; const root = container.firstElementChild;
expect(root?.getAttribute('data-summary-chart-kind')).toBe('density-map'); 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(root?.getAttribute('data-rendered-series-count')).toBe('2');
expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Latest')).toBeInTheDocument(); expect(screen.getByText('Latest')).toBeInTheDocument();
@ -253,4 +254,39 @@ describe('DensityMap', () => {
expect(overlay).not.toBeNull(); expect(overlay).not.toBeNull();
expect(overlay).toHaveAttribute('data-density-map-focus-series-id', 'alpha'); 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); 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 () => { it('limits tooltip rows and shows the "+N more series" affordance', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));

View file

@ -9,6 +9,7 @@ export interface DensityMapProps {
rangeLabel?: string; rangeLabel?: string;
timeRange?: TimeRange; timeRange?: TimeRange;
formatValue?: (value: number) => string; formatValue?: (value: number) => string;
focusEmptyStateLabel?: string;
hoverSourceKey?: string; hoverSourceKey?: string;
hoverSync?: SummaryChartHoverSync | null; hoverSync?: SummaryChartHoverSync | null;
onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void; onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
@ -167,6 +168,25 @@ export function getDensityMapExternalSeriesIndex(
return index >= 0 ? index : null; 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: { export function buildDensityMapHoveredState(options: {
clientX: number; clientX: number;
clientY: 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: { const buildDensityMapFocusSparklinePath = (options: {
points: Array<{ timestamp: number; value: number }>; points: Array<{ timestamp: number; value: number }>;
rangeMs: number; rangeMs: number;

View file

@ -432,6 +432,26 @@ export const getInteractiveSparklineExternalSeriesIndex = (
return index >= 0 ? index : null; 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 = ({ export const getInteractiveSparklineActiveEmphasisSeriesIndex = ({
highlightNearestSeriesOnHover, highlightNearestSeriesOnHover,
lockedSeriesIndex, lockedSeriesIndex,

View file

@ -2,8 +2,9 @@ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { import {
buildDensityMapChartData, buildDensityMapChartData,
buildDensityMapFocusDetail, buildDensityMapFocusDetail,
getDensityMapExternalSeriesIndex,
buildDensityMapHoveredState, buildDensityMapHoveredState,
getDensityMapColumnIndexForTimestamp,
getDensityMapExternalSeriesIndex,
buildDensityMapSynchronizedHoveredState, buildDensityMapSynchronizedHoveredState,
formatDensityMapValue, formatDensityMapValue,
getDensityMapCellOpacity, getDensityMapCellOpacity,
@ -41,9 +42,25 @@ export function useDensityMapState(props: DensityMapProps) {
hoverSync, 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>(() => { const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
return hoveredState() ?? synchronizedHoveredState(); return hoveredState() ?? synchronizedHoveredState();
}); });
const activeHoverTimestamp = createMemo<number | null>(() => {
return hoveredState()?.timestamp ?? synchronizedHoverTimestamp();
});
const activeColumnIndex = createMemo<number | null>(() =>
getDensityMapColumnIndexForTimestamp(chartData(), activeHoverTimestamp()),
);
const focusDetail = createMemo(() => const focusDetail = createMemo(() =>
buildDensityMapFocusDetail({ buildDensityMapFocusDetail({
activeHoveredState: activeHoveredState(), activeHoveredState: activeHoveredState(),
@ -81,6 +98,7 @@ export function useDensityMapState(props: DensityMapProps) {
const cellHeight = height / rows; const cellHeight = height / rows;
const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1; const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1;
const hover = activeHoveredState(); const hover = activeHoveredState();
const highlightColumnIndex = activeColumnIndex();
const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex(); const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex();
const activeSeries = const activeSeries =
activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length
@ -137,8 +155,8 @@ export function useDensityMapState(props: DensityMapProps) {
if (activeSeries !== null && activeSeriesIndex !== null) { if (activeSeries !== null && activeSeriesIndex !== null) {
const highlightY = activeSeriesIndex * cellHeight; const highlightY = activeSeriesIndex * cellHeight;
context.save(); context.save();
if (hover) { if (highlightColumnIndex !== null) {
const highlightX = hover.columnIndex * cellWidth; const highlightX = highlightColumnIndex * cellWidth;
context.globalAlpha = 0.12 * interactionOpacity; context.globalAlpha = 0.12 * interactionOpacity;
context.fillStyle = activeSeries.color; context.fillStyle = activeSeries.color;
context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height); context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height);
@ -159,6 +177,15 @@ export function useDensityMapState(props: DensityMapProps) {
context.restore(); 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; context.globalAlpha = 1;
}; };
@ -209,6 +236,8 @@ export function useDensityMapState(props: DensityMapProps) {
return { return {
activeHoveredState, activeHoveredState,
activeColumnIndex,
activeHoverTimestamp,
chartData, chartData,
externalSeriesIndex, externalSeriesIndex,
focusDetail, focusDetail,

View file

@ -3,6 +3,7 @@ import { scheduleSparkline, setupCanvasDPR } from '@/utils/canvasRenderQueue';
import { import {
buildInteractiveSparklineAxisTicks, buildInteractiveSparklineAxisTicks,
buildInteractiveSparklineChartData, buildInteractiveSparklineChartData,
getInteractiveSparklineCursorXForTimestamp,
buildInteractiveSparklineGridLineX, buildInteractiveSparklineGridLineX,
buildInteractiveSparklineGridLineY, buildInteractiveSparklineGridLineY,
buildInteractiveSparklineSynchronizedHoverState, buildInteractiveSparklineSynchronizedHoverState,
@ -195,9 +196,29 @@ export function useInteractiveSparklineState(
yMode: yMode(), 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>(() => { const activeHoverState = createMemo<InteractiveSparklineHoverState | null>(() => {
return hoveredState() ?? synchronizedHoverState(); 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(() => const activeEmphasisSeriesIndex = createMemo(() =>
getInteractiveSparklineActiveEmphasisSeriesIndex({ getInteractiveSparklineActiveEmphasisSeriesIndex({
highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover === true, highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover === true,
@ -385,11 +406,13 @@ export function useInteractiveSparklineState(
ctx.restore(); ctx.restore();
} }
const cursorX = activeHoverCursorX();
if (cursorX === null) return;
const hover = activeHoverState(); const hover = activeHoverState();
if (!hover) return;
ctx.save(); ctx.save();
const x = (hover.x / vbW) * width; const x = (cursorX / vbW) * width;
const hoverLineGradient = ctx.createLinearGradient(0, Math.max(0, hover.minY - 4), 0, height); 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, 'transparent');
hoverLineGradient.addColorStop(0.1, hoverLineColor); hoverLineGradient.addColorStop(0.1, hoverLineColor);
hoverLineGradient.addColorStop(1, hoverLineColor); hoverLineGradient.addColorStop(1, hoverLineColor);
@ -397,7 +420,7 @@ export function useInteractiveSparklineState(
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, Math.max(0, hover.minY - 4)); ctx.moveTo(x, lineStartY);
ctx.lineTo(x, height); ctx.lineTo(x, height);
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
@ -423,6 +446,7 @@ export function useInteractiveSparklineState(
void chartData(); void chartData();
void activeEmphasisSeriesIndex(); void activeEmphasisSeriesIndex();
void activeHoverState(); void activeHoverState();
void activeHoverCursorX();
queueCanvasDraw(); queueCanvasDraw();
}); });
@ -449,6 +473,8 @@ export function useInteractiveSparklineState(
return { return {
activeEmphasisSeriesIndex, activeEmphasisSeriesIndex,
activeHoverState, activeHoverState,
activeHoverCursorX,
activeHoverTimestamp,
activeSeriesDisplay, activeSeriesDisplay,
axisTicks, axisTicks,
chartData, chartData,

View file

@ -9,10 +9,69 @@
* resolves testDir so that the Playwright runner and test files share the * resolves testDir so that the Playwright runner and test files share the
* same @playwright/test instance (avoiding the "two versions" error). * 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({ export default defineConfig({
testDir: './tests/integration/tests', testDir: "./tests/integration/tests",
fullyParallel: false, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
@ -20,46 +79,48 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: [ reporter: [
['html', { outputFolder: 'tests/integration/playwright-report', open: 'never' }], [
['list'], "html",
['junit', { outputFile: 'tests/integration/test-results/junit.xml' }], { outputFolder: "tests/integration/playwright-report", open: "never" },
],
["list"],
["junit", { outputFile: "tests/integration/test-results/junit.xml" }],
], ],
timeout: 60_000, timeout: 60_000,
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
use: { use: {
baseURL: baseURL: preferredBrowserBaseURL(),
process.env.PULSE_BASE_URL ||
process.env.PLAYWRIGHT_BASE_URL ||
'http://localhost:7655',
ignoreHTTPSErrors: ['1', 'true', 'yes', 'on'].includes( ignoreHTTPSErrors: ["1", "true", "yes", "on"].includes(
String(process.env.PULSE_E2E_INSECURE_TLS || '').trim().toLowerCase(), String(process.env.PULSE_E2E_INSECURE_TLS || "")
.trim()
.toLowerCase(),
), ),
trace: 'on-first-retry', trace: "on-first-retry",
screenshot: 'only-on-failure', screenshot: "only-on-failure",
video: 'retain-on-failure', video: "retain-on-failure",
navigationTimeout: 15_000, navigationTimeout: 15_000,
actionTimeout: 10_000, actionTimeout: 10_000,
}, },
projects: [ projects: [
{ {
name: 'chromium', name: "chromium",
use: { ...devices['Desktop Chrome'] }, use: { ...devices["Desktop Chrome"] },
testIgnore: ['**/04-mobile.spec.ts'], testIgnore: ["**/04-mobile.spec.ts"],
}, },
{ {
name: 'mobile-chrome', name: "mobile-chrome",
use: { ...devices['Pixel 5'] }, use: { ...devices["Pixel 5"] },
testIgnore: ['**/journeys/**'], testIgnore: ["**/journeys/**"],
}, },
{ {
name: 'mobile-safari', name: "mobile-safari",
use: { ...devices['iPhone 12'] }, use: { ...devices["iPhone 12"] },
testIgnore: ['**/journeys/**'], 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()," 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() { test_integration_helpers_prefer_managed_hot_dev_runtime() {
local output local output
output="$(cat "${ROOT_DIR}/tests/integration/tests/helpers.ts")" 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_script_advertises_managed_entrypoint
test_hot_dev_bg_usage_prefers_managed_wrappers test_hot_dev_bg_usage_prefers_managed_wrappers
test_integration_readme_uses_managed_backend_restart_wrapper test_integration_readme_uses_managed_backend_restart_wrapper
test_root_playwright_wrapper_prefers_managed_browser_runtime
test_clean_mock_alerts_prefers_managed_runtime test_clean_mock_alerts_prefers_managed_runtime
test_dev_check_uses_managed_runtime_status test_dev_check_uses_managed_runtime_status
test_backend_restart_requires_managed_runtime 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( async function readSummarySeriesId(
row: import("@playwright/test").Locator, row: import("@playwright/test").Locator,
fallbackAttribute: string, fallbackAttribute: string,
@ -445,6 +467,7 @@ test.describe.serial("Summary hover selection", () => {
infrastructureChartId, infrastructureChartId,
4, 4,
); );
await expectSummaryHoverTimestampsAligned(infrastructureSummary, 4);
await expectActiveIsolatedLineCards(infrastructureSummary, 2); await expectActiveIsolatedLineCards(infrastructureSummary, 2);
await expectActiveDensityMapsPreserveOverview( await expectActiveDensityMapsPreserveOverview(
infrastructureSummary, infrastructureSummary,
@ -464,6 +487,7 @@ test.describe.serial("Summary hover selection", () => {
); );
expect(workloadChartId).not.toBe(""); expect(workloadChartId).not.toBe("");
await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4); await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4);
await expectSummaryHoverTimestampsAligned(workloadsSummary, 4);
await expectActiveIsolatedLineCards(workloadsSummary, 2); await expectActiveIsolatedLineCards(workloadsSummary, 2);
await expectActiveDensityMapsPreserveOverview( await expectActiveDensityMapsPreserveOverview(
workloadsSummary, workloadsSummary,
@ -481,6 +505,7 @@ test.describe.serial("Summary hover selection", () => {
); );
expect(storageChartId).not.toBe(""); expect(storageChartId).not.toBe("");
await expectSummaryHighlightCount(storageSummary, storageChartId, 3); await expectSummaryHighlightCount(storageSummary, storageChartId, 3);
await expectSummaryHoverTimestampsAligned(storageSummary, 4);
await expectActiveIsolatedLineCards(storageSummary, 3); await expectActiveIsolatedLineCards(storageSummary, 3);
}); });
}); });

View file

@ -1,37 +1,42 @@
import { Browser, Page, expect } from '@playwright/test'; import { Browser, Page, expect } from "@playwright/test";
import { preferredBrowserBaseURL, readRuntimeState } from './runtime-defaults'; import { preferredBrowserBaseURL, readRuntimeState } from "./runtime-defaults";
const runtimePrimaryAPIToken = (): string => { const runtimePrimaryAPIToken = (): string => {
const parsed = readRuntimeState(); const parsed = readRuntimeState();
return typeof parsed?.primaryAPIToken === 'string' ? parsed.primaryAPIToken.trim() : ''; return typeof parsed?.primaryAPIToken === "string"
? parsed.primaryAPIToken.trim()
: "";
}; };
/** /**
* Default admin credentials for testing * Default admin credentials for testing
*/ */
export const ADMIN_CREDENTIALS = { export const ADMIN_CREDENTIALS = {
username: 'admin', username: "admin",
// Pulse enforces a minimum password length of 12 characters. // 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 = { export const E2E_CREDENTIALS = {
bootstrapToken: process.env.PULSE_E2E_BOOTSTRAP_TOKEN || DEFAULT_E2E_BOOTSTRAP_TOKEN, bootstrapToken:
primaryApiToken: process.env.PULSE_E2E_PRIMARY_API_TOKEN || runtimePrimaryAPIToken(), 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, username: process.env.PULSE_E2E_USERNAME || ADMIN_CREDENTIALS.username,
password: process.env.PULSE_E2E_PASSWORD || ADMIN_CREDENTIALS.password, password: process.env.PULSE_E2E_PASSWORD || ADMIN_CREDENTIALS.password,
}; };
async function waitForAppShell(page: Page, timeoutMs = 20_000) { 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 // The raw HTML shell contains a noscript fallback. Wait for the SPA to
// mount before making route-specific assertions against wizard/login UI. // mount before making route-specific assertions against wizard/login UI.
await page.waitForFunction( await page.waitForFunction(
() => { () => {
const root = document.getElementById('root'); const root = document.getElementById("root");
return root !== null && root.children.length > 0; return root !== null && root.children.length > 0;
}, },
undefined, undefined,
@ -44,7 +49,7 @@ export async function waitForPulseReady(page: Page, timeoutMs = 120_000) {
let lastError: unknown = null; let lastError: unknown = null;
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
try { try {
const res = await page.request.get('/api/health'); const res = await page.request.get("/api/health");
if (res.ok()) { if (res.ok()) {
return; return;
} }
@ -54,7 +59,7 @@ export async function waitForPulseReady(page: Page, timeoutMs = 120_000) {
} }
await page.waitForTimeout(1000); 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 = { type SecurityStatus = {
@ -65,22 +70,22 @@ type ResetFirstRunResponse = {
bootstrapToken?: string; bootstrapToken?: string;
}; };
type SetupCompletionTarget = 'install' | 'platforms' | 'none'; type SetupCompletionTarget = "install" | "platforms" | "none";
type CompleteSetupWizardOptions = { type CompleteSetupWizardOptions = {
completionTarget?: SetupCompletionTarget; completionTarget?: SetupCompletionTarget;
}; };
const SETUP_COMPLETION_HANDOFFS: Record< const SETUP_COMPLETION_HANDOFFS: Record<
Exclude<SetupCompletionTarget, 'none'>, Exclude<SetupCompletionTarget, "none">,
{ buttonName: string; urlPattern: RegExp } { buttonName: string; urlPattern: RegExp }
> = { > = {
install: { install: {
buttonName: 'Open Infrastructure Install', buttonName: "Open Infrastructure Install",
urlPattern: /\/settings\/infrastructure\/install/, urlPattern: /\/settings\/infrastructure\/install/,
}, },
platforms: { platforms: {
buttonName: 'Open Platform connections', buttonName: "Open Platform connections",
urlPattern: /\/settings\/infrastructure\/platforms/, urlPattern: /\/settings\/infrastructure\/platforms/,
}, },
}; };
@ -90,92 +95,128 @@ async function completeSetupWizard(
bootstrapToken: string, bootstrapToken: string,
options: CompleteSetupWizardOptions = {}, options: CompleteSetupWizardOptions = {},
) { ) {
const completionTarget = options.completionTarget ?? 'install'; const completionTarget = options.completionTarget ?? "install";
if (!bootstrapToken) { 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); 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(); 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, name: /install your first monitored host|first monitored host connected/i,
}); });
const openInstallWorkspaceButton = wizard.getByRole('button', { const openInstallWorkspaceButton = wizard.getByRole("button", {
name: 'Open Infrastructure Install', name: "Open Infrastructure Install",
exact: true, exact: true,
}); });
const openPlatformConnectionsButton = wizard.getByRole('button', { const openPlatformConnectionsButton = wizard.getByRole("button", {
name: 'Open Platform connections', name: "Open Platform connections",
exact: true, exact: true,
}); });
const secureDashboardHeading = wizard.getByText('Secure Your Dashboard'); const secureDashboardHeading = wizard.getByText("Secure Your Dashboard");
const continueButton = wizard.getByRole('button', { const continueButton = wizard.getByRole("button", {
name: /verify bootstrap token|continue to setup|continue/i, name: /verify bootstrap token|continue to setup|continue/i,
}); });
const finishButton = wizard.getByRole('button', { name: /go to dashboard|skip for now/i }); const finishButton = wizard.getByRole("button", {
const bootstrapTokenInput = page.getByPlaceholder('Paste your bootstrap token'); name: /go to dashboard|skip for now/i,
});
const bootstrapTokenInput = page.getByPlaceholder(
"Paste your bootstrap token",
);
await bootstrapTokenInput.click(); await bootstrapTokenInput.click();
await bootstrapTokenInput.fill(''); await bootstrapTokenInput.fill("");
await bootstrapTokenInput.pressSequentially(bootstrapToken, { delay: 10 }); await bootstrapTokenInput.pressSequentially(bootstrapToken, { delay: 10 });
const detectWizardStep = async (): Promise<'security' | 'completion' | 'pending'> => { const detectWizardStep = async (): Promise<
"security" | "completion" | "pending"
> => {
if ( if (
await completionHeading.isVisible({ timeout: 100 }).catch(() => false) || (await completionHeading
await openInstallWorkspaceButton.isVisible({ timeout: 100 }).catch(() => false) || .isVisible({ timeout: 100 })
await openPlatformConnectionsButton.isVisible({ timeout: 100 }).catch(() => false) .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)) { if (
return 'security'; 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 // 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 // is long enough. Only fall back to the explicit verify button if the step
// stays put after that auto-submit window. // stays put after that auto-submit window.
let wizardStep = await detectWizardStep(); let wizardStep = await detectWizardStep();
if (wizardStep === 'pending') { if (wizardStep === "pending") {
await expect.poll(detectWizardStep, { timeout: 10_000 }).not.toBe('pending').catch(() => {}); await expect
.poll(detectWizardStep, { timeout: 10_000 })
.not.toBe("pending")
.catch(() => {});
wizardStep = await detectWizardStep(); wizardStep = await detectWizardStep();
} }
if ( if (
wizardStep === 'pending' && wizardStep === "pending" &&
await continueButton.isVisible({ timeout: 250 }).catch(() => false) && (await continueButton.isVisible({ timeout: 250 }).catch(() => false)) &&
await continueButton.isEnabled().catch(() => false) (await continueButton.isEnabled().catch(() => false))
) { ) {
await continueButton.click({ timeout: 1_000 }).catch(() => {}); 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(); wizardStep = await detectWizardStep();
} }
const onSecurityStep = wizardStep === 'security'; const onSecurityStep = wizardStep === "security";
let onCompleteStep = wizardStep === 'completion'; let onCompleteStep = wizardStep === "completion";
if (!onSecurityStep && !onCompleteStep) { 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) { if (onSecurityStep) {
const customPasswordButton = wizard.getByRole('button', { name: /custom password/i }); const customPasswordButton = wizard.getByRole("button", {
if (await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false)) { name: /custom password/i,
});
if (
await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false)
) {
let clickedCustomPassword = false; let clickedCustomPassword = false;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
await customPasswordButton.click({ timeout: 10_000, force: attempt > 0 }); await customPasswordButton.click({
timeout: 10_000,
force: attempt > 0,
});
clickedCustomPassword = true; clickedCustomPassword = true;
break; break;
} catch (error) { } catch (error) {
if ( if (
await completionHeading.isVisible({ timeout: 250 }).catch(() => false) || (await completionHeading
await openInstallWorkspaceButton.isVisible({ timeout: 250 }).catch(() => false) || .isVisible({ timeout: 250 })
await openPlatformConnectionsButton.isVisible({ timeout: 250 }).catch(() => false) .catch(() => false)) ||
(await openInstallWorkspaceButton
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await openPlatformConnectionsButton
.isVisible({ timeout: 250 })
.catch(() => false))
) { ) {
onCompleteStep = true; onCompleteStep = true;
break; break;
@ -188,29 +229,44 @@ async function completeSetupWizard(
} }
if (!onCompleteStep && clickedCustomPassword) { if (!onCompleteStep && clickedCustomPassword) {
await wizard.locator('input[type="text"]').first().fill(E2E_CREDENTIALS.username); await wizard
await wizard.locator('input[type="password"]').nth(0).fill(E2E_CREDENTIALS.password); .locator('input[type="text"]')
await wizard.locator('input[type="password"]').nth(1).fill(E2E_CREDENTIALS.password); .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 await expect
.poll(async () => { .poll(async () => {
if ( if (
await completionHeading.isVisible({ timeout: 250 }).catch(() => false) || (await completionHeading
await openInstallWorkspaceButton.isVisible({ timeout: 250 }).catch(() => false) || .isVisible({ timeout: 250 })
await openPlatformConnectionsButton.isVisible({ timeout: 250 }).catch(() => false) .catch(() => false)) ||
(await openInstallWorkspaceButton
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await openPlatformConnectionsButton
.isVisible({ timeout: 250 })
.catch(() => false))
) { ) {
return 'complete'; return "complete";
} }
if ( if (
completionTarget === 'install' && completionTarget === "install" &&
SETUP_COMPLETION_HANDOFFS.install.urlPattern.test(page.url()) SETUP_COMPLETION_HANDOFFS.install.urlPattern.test(page.url())
) { ) {
return 'handoff'; return "handoff";
} }
return 'pending'; return "pending";
}) })
.not.toBe('pending'); .not.toBe("pending");
onCompleteStep = true; onCompleteStep = true;
} }
} else { } else {
@ -219,14 +275,16 @@ async function completeSetupWizard(
} }
} }
if (onCompleteStep && completionTarget !== 'none') { if (onCompleteStep && completionTarget !== "none") {
const completionAction = SETUP_COMPLETION_HANDOFFS[completionTarget]; const completionAction = SETUP_COMPLETION_HANDOFFS[completionTarget];
if (!completionAction.urlPattern.test(page.url())) { if (!completionAction.urlPattern.test(page.url())) {
const completionButton = wizard.getByRole('button', { const completionButton = wizard.getByRole("button", {
name: completionAction.buttonName, name: completionAction.buttonName,
exact: true, exact: true,
}); });
const completionVisible = await completionButton.isVisible({ timeout: 500 }).catch(() => false); const completionVisible = await completionButton
.isVisible({ timeout: 500 })
.catch(() => false);
if (completionVisible) { if (completionVisible) {
await completionButton.scrollIntoViewIfNeeded(); await completionButton.scrollIntoViewIfNeeded();
@ -241,18 +299,20 @@ async function completeSetupWizard(
); );
} }
} else if (onCompleteStep) { } else if (onCompleteStep) {
const finishVisible = await finishButton.isVisible({ timeout: 500 }).catch(() => false); const finishVisible = await finishButton
.isVisible({ timeout: 500 })
.catch(() => false);
if (finishVisible) { if (finishVisible) {
await finishButton.scrollIntoViewIfNeeded(); await finishButton.scrollIntoViewIfNeeded();
await finishButton.click({ timeout: 10_000 }); await finishButton.click({ timeout: 10_000 });
} }
} }
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState("domcontentloaded");
} }
export async function getSecurityStatus(page: Page): Promise<SecurityStatus> { 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()) { if (!res.ok()) {
throw new Error(`Failed to fetch security status: ${res.status()}`); throw new Error(`Failed to fetch security status: ${res.status()}`);
} }
@ -273,43 +333,53 @@ export async function ensureFirstRunExperience(
options: CompleteSetupWizardOptions = {}, options: CompleteSetupWizardOptions = {},
) { ) {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true'); localStorage.setItem("pulse_whats_new_v2_shown", "true");
}); });
await waitForPulseReady(page); await waitForPulseReady(page);
const completionTarget = options.completionTarget ?? 'install'; const completionTarget = options.completionTarget ?? "install";
let bootstrapToken = E2E_CREDENTIALS.bootstrapToken; let bootstrapToken = E2E_CREDENTIALS.bootstrapToken;
const security = await getSecurityStatus(page); const security = await getSecurityStatus(page);
let resetRes = await apiRequest(page, '/api/security/dev/reset-first-run', { let resetRes = await apiRequest(page, "/api/security/dev/reset-first-run", {
method: 'POST', method: "POST",
headers: E2E_CREDENTIALS.primaryApiToken headers: E2E_CREDENTIALS.primaryApiToken
? { 'X-API-Token': E2E_CREDENTIALS.primaryApiToken } ? { "X-API-Token": E2E_CREDENTIALS.primaryApiToken }
: undefined, : undefined,
}); });
if (resetRes.status() === 401 && !E2E_CREDENTIALS.primaryApiToken && security.hasAuthentication !== false) { if (
resetRes.status() === 401 &&
!E2E_CREDENTIALS.primaryApiToken &&
security.hasAuthentication !== false
) {
await login(page); await login(page);
resetRes = await apiRequest(page, '/api/security/dev/reset-first-run', { resetRes = await apiRequest(page, "/api/security/dev/reset-first-run", {
method: 'POST', method: "POST",
}); });
} }
if (!resetRes.ok()) { 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; const payload = (await resetRes.json()) as ResetFirstRunResponse;
bootstrapToken = String(payload.bootstrapToken || '').trim(); bootstrapToken = String(payload.bootstrapToken || "").trim();
if (bootstrapToken === '') { if (bootstrapToken === "") {
throw new Error('First-run reset response did not include a bootstrap token'); throw new Error(
"First-run reset response did not include a bootstrap token",
);
} }
await completeSetupWizard(page, bootstrapToken, { completionTarget }); await completeSetupWizard(page, bootstrapToken, { completionTarget });
const firstRunLandingPattern = const firstRunLandingPattern =
completionTarget === 'platforms' completionTarget === "platforms"
? SETUP_COMPLETION_HANDOFFS.platforms.urlPattern ? SETUP_COMPLETION_HANDOFFS.platforms.urlPattern
: /\/(settings\/infrastructure\/install|proxmox|dashboard|nodes|hosts|docker|infrastructure)/; : /\/(settings\/infrastructure\/install|proxmox|dashboard|nodes|hosts|docker|infrastructure)/;
if (!firstRunLandingPattern.test(page.url())) { if (!firstRunLandingPattern.test(page.url())) {
if (completionTarget === 'platforms') { if (completionTarget === "platforms") {
throw new Error(`First-run setup did not reach Platform connections: ${page.url()}`); throw new Error(
`First-run setup did not reach Platform connections: ${page.url()}`,
);
} }
await login(page); await login(page);
} }
@ -320,8 +390,8 @@ export async function ensureFirstRunExperience(
* Login as admin user * Login as admin user
*/ */
export async function loginAsAdmin(page: Page) { export async function loginAsAdmin(page: Page) {
await page.goto('/'); await page.goto("/");
await page.waitForSelector('input[name="username"]', { state: 'visible' }); await page.waitForSelector('input[name="username"]', { state: "visible" });
await page.fill('input[name="username"]', E2E_CREDENTIALS.username); await page.fill('input[name="username"]', E2E_CREDENTIALS.username);
await page.fill('input[name="password"]', E2E_CREDENTIALS.password); await page.fill('input[name="password"]', E2E_CREDENTIALS.password);
await page.click('button[type="submit"]'); 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) { export async function login(page: Page, credentials = E2E_CREDENTIALS) {
await page.goto('/'); await page.goto("/");
await waitForAppShell(page); 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 usernameInput = page.locator('input[name="username"]');
const state = await Promise.race([ const state = await Promise.race([
usernameInput usernameInput
.waitFor({ state: 'visible', timeout: 15_000 }) .waitFor({ state: "visible", timeout: 15_000 })
.then(() => 'login') .then(() => "login")
.catch(() => undefined), .catch(() => undefined),
page page
.waitForURL(authenticatedURL, { timeout: 15_000 }) .waitForURL(authenticatedURL, { timeout: 15_000 })
.then(() => 'authenticated') .then(() => "authenticated")
.catch(() => undefined), .catch(() => undefined),
]); ]);
if (state === 'authenticated') { if (state === "authenticated") {
return; return;
} }
if (state !== 'login') { if (state !== "login") {
const url = page.url(); const url = page.url();
const preview = ((await page.locator('body').textContent()) || '').replace(/\s+/g, ' ').slice(0, 200); const preview = ((await page.locator("body").textContent()) || "")
throw new Error(`Login did not render and did not redirect (url=${url}, body="${preview}")`); .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( const loginErrorText = page
'text=/Invalid username or password|Too many requests|Account locked|Failed to connect to server|Server error/i', .locator(
).first(); "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="username"]', credentials.username);
await page.fill('input[name="password"]', credentials.password); await page.fill('input[name="password"]', credentials.password);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect.poll( await expect
async () => { .poll(
const url = page.url(); async () => {
if (authenticatedURL.test(url)) { const url = page.url();
return 'authenticated'; if (authenticatedURL.test(url)) {
} return "authenticated";
}
const loginErrorVisible = await loginErrorText.isVisible().catch(() => false); const loginErrorVisible = await loginErrorText
if (loginErrorVisible) { .isVisible()
const message = ((await loginErrorText.textContent()) || 'login_error').trim(); .catch(() => false);
return `error:${message}`; if (loginErrorVisible) {
} const message = (
(await loginErrorText.textContent()) || "login_error"
).trim();
return `error:${message}`;
}
const stillShowingLogin = await usernameInput.isVisible().catch(() => false); const stillShowingLogin = await usernameInput
if (stillShowingLogin) { .isVisible()
return 'login'; .catch(() => false);
} if (stillShowingLogin) {
return "login";
}
return 'pending'; return "pending";
}, },
{ {
timeout: 30_000, timeout: 30_000,
message: 'Timed out waiting for authenticated app state after login submission', message:
}, "Timed out waiting for authenticated app state after login submission",
).toBe('authenticated'); },
)
.toBe("authenticated");
} }
/** /**
@ -400,7 +486,7 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) {
*/ */
export async function dismissWhatsNewModal(page: Page): Promise<void> { export async function dismissWhatsNewModal(page: Page): Promise<void> {
await page.evaluate(() => { 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" // 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. // overlay from appearing and blocking clicks (logout, row taps, etc.) in tests.
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true'); localStorage.setItem("pulse_whats_new_v2_shown", "true");
}); });
await waitForPulseReady(page); await waitForPulseReady(page);
await maybeCompleteSetupWizard(page); await maybeCompleteSetupWizard(page);
await login(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( export async function createAuthenticatedStorageState(
@ -442,11 +530,13 @@ export async function logout(page: Page) {
} }
export async function setMockMode(page: Page, enabled: boolean) { export async function setMockMode(page: Page, enabled: boolean) {
const send = () => apiRequest(page, '/api/system/mock-mode', { await waitForPulseReady(page);
method: 'POST', const send = () =>
data: { enabled }, apiRequest(page, "/api/system/mock-mode", {
headers: { 'Content-Type': 'application/json' }, method: "POST",
}); data: { enabled },
headers: { "Content-Type": "application/json" },
});
// Mock mode toggle can fail transiently when the backend is still // Mock mode toggle can fail transiently when the backend is still
// processing a previous toggle (e.g. between consecutive suite runs). // 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) { 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(); for (let attempt = 0; attempt < 3; attempt++) {
if (res.status() === 401) { try {
await login(page); let res = await send();
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(
throw new Error(`Failed to read mock mode: ${res.status()} ${await res.text()}`); `Failed to read mock mode after 3 attempts: ${lastError?.message}`,
} );
return (await res.json()) as { enabled: boolean };
} }
/** /**
* Navigate to settings page * Navigate to settings page
*/ */
export async function navigateToSettings(page: 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") // 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. // 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 * Wait for update banner to appear
*/ */
export async function waitForUpdateBanner(page: Page, timeout = 30000) { 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 }); await expect(banner).toBeVisible({ timeout });
return banner; return banner;
} }
@ -517,7 +628,10 @@ export async function waitForUpdateBanner(page: Page, timeout = 30000) {
* Click "Apply Update" button in update banner * Click "Apply Update" button in update banner
*/ */
export async function clickApplyUpdate(page: Page) { 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 expect(applyButton).toBeVisible();
await applyButton.click(); await applyButton.click();
} }
@ -526,7 +640,10 @@ export async function clickApplyUpdate(page: Page) {
* Wait for update confirmation modal * Wait for update confirmation modal
*/ */
export async function waitForConfirmationModal(page: Page) { 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 }); await expect(modal).toBeVisible({ timeout: 10000 });
return modal; return modal;
} }
@ -542,7 +659,10 @@ export async function confirmUpdate(page: Page) {
} }
// Click confirm button // 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(); await confirmButton.click();
} }
@ -550,7 +670,8 @@ export async function confirmUpdate(page: Page) {
* Wait for update progress modal * Wait for update progress modal
*/ */
export async function waitForProgressModal(page: Page) { 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 }) .filter({ hasText: /updating|progress|downloading/i })
.first(); .first();
await expect(modal).toBeVisible({ timeout: 10000 }); await expect(modal).toBeVisible({ timeout: 10000 });
@ -561,7 +682,9 @@ export async function waitForProgressModal(page: Page) {
* Count visible modals on page * Count visible modals on page
*/ */
export async function countVisibleModals(page: Page): Promise<number> { 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(); return await modals.count();
} }
@ -569,7 +692,7 @@ export async function countVisibleModals(page: Page): Promise<number> {
* Wait for error message in modal * Wait for error message in modal
*/ */
export async function waitForErrorInModal(page: Page, modal: any) { 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 }); await expect(errorText).toBeVisible({ timeout: 30000 });
return errorText; return errorText;
} }
@ -592,20 +715,27 @@ export async function assertUserFriendlyError(errorText: string) {
*/ */
export async function dismissModal(page: Page) { export async function dismissModal(page: Page) {
// Try close button first // 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)) { if (await closeButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await closeButton.click(); await closeButton.click();
return; return;
} }
// Try ESC key // Try ESC key
await page.keyboard.press('Escape'); await page.keyboard.press("Escape");
} }
/** /**
* Wait for progress to reach a certain percentage * 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( await page.waitForFunction(
({ modalSelector, min }) => { ({ modalSelector, min }) => {
const modal = document.querySelector(modalSelector); 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 // Check for progress bar or percentage text
const progressBar = modal.querySelector('[role="progressbar"]'); const progressBar = modal.querySelector('[role="progressbar"]');
if (progressBar) { if (progressBar) {
const value = progressBar.getAttribute('aria-valuenow'); const value = progressBar.getAttribute("aria-valuenow");
return value && parseInt(value) >= min; return value && parseInt(value) >= min;
} }
// Check for percentage text // Check for percentage text
const text = modal.textContent || ''; const text = modal.textContent || "";
const match = text.match(/(\d+)%/); const match = text.match(/(\d+)%/);
return match && parseInt(match[1]) >= min; return match && parseInt(match[1]) >= min;
}, },
{ modalSelector: '[role="dialog"]', min: minPercent }, { 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 // This would be implemented by CI/test runner to restart containers
// with new environment variables // 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 * Make API request to Pulse backend
*/ */
export async function apiRequest(page: Page, endpoint: string, options: any = {}) { export async function apiRequest(
const baseURL = preferredBrowserBaseURL().replace(/\/+$/, ''); 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 headers = { ...(options.headers || {}) } as Record<string, string>;
const hasNonSessionAuth = const hasNonSessionAuth =
typeof headers.Authorization === 'string' && (typeof headers.Authorization === "string" &&
/^(basic|bearer)\s+/i.test(headers.Authorization) || /^(basic|bearer)\s+/i.test(headers.Authorization)) ||
typeof headers['X-API-Token'] === 'string'; typeof headers["X-API-Token"] === "string";
if (!hasNonSessionAuth && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { if (!hasNonSessionAuth && !["GET", "HEAD", "OPTIONS"].includes(method)) {
const hasCSRFHeader = Object.keys(headers).some((name) => name.toLowerCase() === 'x-csrf-token'); const hasCSRFHeader = Object.keys(headers).some(
(name) => name.toLowerCase() === "x-csrf-token",
);
if (!hasCSRFHeader) { if (!hasCSRFHeader) {
const cookies = await page.context().cookies(baseURL); 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) { 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> { 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(); return orgsRes.ok();
} }
const toOrgID = (displayName: string) => { const toOrgID = (displayName: string) => {
const base = displayName const base =
.toLowerCase() displayName
.replace(/[^a-z0-9-]+/g, '-') .toLowerCase()
.replace(/^-+|-+$/g, '') .replace(/[^a-z0-9-]+/g, "-")
.slice(0, 36) || 'org'; .replace(/^-+|-+$/g, "")
.slice(0, 36) || "org";
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
return `${base}-${suffix}`.slice(0, 64); return `${base}-${suffix}`.slice(0, 64);
}; };
export async function createOrg(page: Page, displayName: string): Promise<{ id: string }> { export async function createOrg(
const res = await apiRequest(page, '/api/orgs', { page: Page,
method: 'POST', displayName: string,
): Promise<{ id: string }> {
const res = await apiRequest(page, "/api/orgs", {
method: "POST",
data: { id: toOrgID(displayName), displayName }, 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 }; const payload = (await res.json()) as { id?: string };
if (!payload.id) { 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 }; 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> { export async function deleteOrg(page: Page, orgId: string): Promise<void> {
const res = await apiRequest(page, `/api/orgs/${encodeURIComponent(orgId)}`, { const res = await apiRequest(page, `/api/orgs/${encodeURIComponent(orgId)}`, {
method: 'DELETE', method: "DELETE",
}); });
if (!res.ok() && res.status() !== 404) { 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> { export async function switchOrg(page: Page, orgId: string): Promise<void> {
await page.evaluate((id) => { await page.evaluate((id) => {
window.sessionStorage.setItem('pulse_org_id', id); window.sessionStorage.setItem("pulse_org_id", id);
window.localStorage.setItem('pulse_org_id', id); window.localStorage.setItem("pulse_org_id", id);
document.cookie = `pulse_org_id=${encodeURIComponent(id)}; Path=/; SameSite=Lax`; document.cookie = `pulse_org_id=${encodeURIComponent(id)}; Path=/; SameSite=Lax`;
}, orgId); }, orgId);
await page.reload(); await page.reload();
await page.waitForLoadState('networkidle'); await page.waitForLoadState("networkidle");
} }
/** /**
* Check for updates via API * Check for updates via API
*/ */
export async function checkForUpdatesAPI(page: Page, channel: 'stable' | 'rc' = 'stable') { export async function checkForUpdatesAPI(
const response = await apiRequest(page, `/api/updates/check?channel=${channel}`); page: Page,
channel: "stable" | "rc" = "stable",
) {
const response = await apiRequest(
page,
`/api/updates/check?channel=${channel}`,
);
return response.json(); return response.json();
} }
@ -744,9 +897,9 @@ export async function checkForUpdatesAPI(page: Page, channel: 'stable' | 'rc' =
* Apply update via API * Apply update via API
*/ */
export async function applyUpdateAPI(page: Page, downloadUrl: string) { export async function applyUpdateAPI(page: Page, downloadUrl: string) {
const response = await apiRequest(page, '/api/updates/apply', { const response = await apiRequest(page, "/api/updates/apply", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
data: { url: downloadUrl }, data: { url: downloadUrl },
}); });
return response; return response;
@ -756,7 +909,7 @@ export async function applyUpdateAPI(page: Page, downloadUrl: string) {
* Get update status via API * Get update status via API
*/ */
export async function getUpdateStatusAPI(page: Page) { 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(); return response.json();
} }
@ -785,7 +938,7 @@ export class Timer {
export async function pollUntil<T>( export async function pollUntil<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
condition: (result: T) => boolean, condition: (result: T) => boolean,
options: { timeout?: number; interval?: number } = {} options: { timeout?: number; interval?: number } = {},
): Promise<T> { ): Promise<T> {
const timeout = options.timeout || 30000; const timeout = options.timeout || 30000;
const interval = options.interval || 1000; const interval = options.interval || 1000;
@ -796,7 +949,7 @@ export async function pollUntil<T>(
if (condition(result)) { if (condition(result)) {
return 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`); throw new Error(`Polling timed out after ${timeout}ms`);