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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue