mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
fix(frontend): sync summary hover cursor and proof
This commit is contained in:
parent
76ba35a3a9
commit
cabd224987
21 changed files with 773 additions and 257 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/**"],
|
||||
},
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue