mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 11:45:52 +00:00
Add branch-coverage tests for finding, cache and badge presentation modules
aiFindingPresentation had grown a set of badge, tone, label and Patrol classification helpers its existing suites never called, so this covers 25 previously-uncovered functions plus their fallback and count-edge arms. workloadsSummaryCache goes from 28 to 76 covered branches across cache hit/miss/expiry paths, infrastructureSummaryCache picks up its remaining getter and branch arms, and resourceBadgePresentation covers a further block of edge branches its two branchcov suites left open. Four new test-only files with distinct branchcov0717 suffixes alongside the existing suites. No source changed. Verified with vitest and type-check, each measured to raise branch or function coverage over the pre-existing suite.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ChartData,
|
||||
ChartsResponse,
|
||||
InfrastructureChartsResponse,
|
||||
InfrastructureSummaryMetric,
|
||||
TimeRange,
|
||||
} from '@/api/charts';
|
||||
import { setOrgID } from '@/utils/apiClient';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import {
|
||||
__resetInfrastructureSummaryFetchesForTests,
|
||||
extractInfrastructureSummaryChartMap,
|
||||
extractInfrastructureSummaryChartMapFromInfrastructureResponse,
|
||||
fetchInfrastructureSummaryAndCache,
|
||||
persistInfrastructureSummaryCache,
|
||||
readInfrastructureSummaryCache,
|
||||
} from '@/utils/infrastructureSummaryCache';
|
||||
|
||||
// ChartsAPI is mocked (keeping `apiClient`, `orgScope` and `events` real) so the
|
||||
// fetch entry point is deterministic and offline — the same isolation strategy
|
||||
// the sibling `infrastructureSummaryCache.branchcov.test.ts` uses. Helpers below
|
||||
// mirror its fixture shape so the assertions stay concrete.
|
||||
const mockGetCharts = vi.fn();
|
||||
|
||||
vi.mock('@/api/charts', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/charts')>('@/api/charts');
|
||||
return {
|
||||
...actual,
|
||||
ChartsAPI: {
|
||||
...actual.ChartsAPI,
|
||||
getInfrastructureSummaryCharts: (...args: unknown[]) => mockGetCharts(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_METRICS_KEY = 'cpu,memory,disk,diskread,diskwrite,netin,netout';
|
||||
|
||||
const cacheKeyForRange = (
|
||||
range: TimeRange,
|
||||
orgScope = 'default',
|
||||
metrics = DEFAULT_METRICS_KEY,
|
||||
) => `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`;
|
||||
|
||||
interface MetricPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const metricPoint = (timestamp: number, value: number): MetricPoint => ({ timestamp, value });
|
||||
|
||||
// Series of N points starting at `start`, stepping by `step` ms.
|
||||
const makeSeries = (count: number, start = 0, step = 30_000): MetricPoint[] =>
|
||||
Array.from({ length: count }, (_, i) => metricPoint(start + i * step, i));
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
const makeInfraResponse = (
|
||||
overrides: Partial<InfrastructureChartsResponse> = {},
|
||||
): InfrastructureChartsResponse => ({
|
||||
nodeData: {},
|
||||
timestamp: now(),
|
||||
stats: { oldestDataTimestamp: now() - 60_000 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Shape of the JSON payload persistInfrastructureSummaryCache writes; used to
|
||||
// hand-craft localStorage entries that exercise readInfrastructureSummaryCache's
|
||||
// coercion branches directly.
|
||||
interface StoredPayload {
|
||||
version: number;
|
||||
range: TimeRange;
|
||||
cachedAt: number;
|
||||
metrics: InfrastructureSummaryMetric[];
|
||||
oldestDataTimestamp: number | null;
|
||||
charts: Record<string, Record<string, unknown>> | undefined;
|
||||
}
|
||||
|
||||
const buildPayload = (overrides: Partial<StoredPayload> = {}): StoredPayload => ({
|
||||
version: 5,
|
||||
range: '1h',
|
||||
cachedAt: now(),
|
||||
metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'],
|
||||
oldestDataTimestamp: null,
|
||||
charts: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const storePayload = (
|
||||
range: TimeRange,
|
||||
payload: StoredPayload,
|
||||
orgScope = 'default',
|
||||
metrics = DEFAULT_METRICS_KEY,
|
||||
) => {
|
||||
window.localStorage.setItem(cacheKeyForRange(range, orgScope, metrics), JSON.stringify(payload));
|
||||
};
|
||||
|
||||
// Replace a global property with `undefined` for the duration of a callback, then
|
||||
// restore the original descriptor. Used to exercise the `typeof window ===
|
||||
// 'undefined'` and `typeof performance === 'undefined'` early-return / fallback
|
||||
// arms that the jsdom test environment otherwise keeps firmly on the happy path.
|
||||
const withGlobalUndefined = <T>(name: 'window' | 'performance', fn: () => T): T => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
// No pre-existing descriptor: leave the property undefined (matches the
|
||||
// pre-test state we just established) but ensure it stays configurable.
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe('extractInfrastructureSummaryChartMap — direct call coverage (uncovered getter)', () => {
|
||||
// extractInfrastructureSummaryChartMap is only reached in production via
|
||||
// extractInfrastructureSummaryChartMapFromInfrastructureResponse, which always
|
||||
// supplies `nodeData: response.nodeData ?? {}`. That keeps the inner
|
||||
// `if (response.nodeData)` arm firmly truthy. Calling the getter directly
|
||||
// with an absent nodeData covers the falsy arm of all three optional fields.
|
||||
|
||||
it('returns an empty map when called directly with every data field absent', () => {
|
||||
const empty = {
|
||||
data: {},
|
||||
storageData: {},
|
||||
timestamp: now(),
|
||||
stats: { oldestDataTimestamp: now() },
|
||||
} as unknown as ChartsResponse;
|
||||
const map = extractInfrastructureSummaryChartMap(empty);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty map when only nodeData is absent (covers the falsy nodeData arm directly)', () => {
|
||||
// agentData/dockerHostData falsy arms are already reached via the wrapper,
|
||||
// but the wrapper never forwards an absent nodeData, so this is the only
|
||||
// way to take the `if (response.nodeData)` false branch.
|
||||
const response = {
|
||||
data: {},
|
||||
nodeData: undefined,
|
||||
storageData: {},
|
||||
agentData: { 'agent-1': { cpu: makeSeries(1) } },
|
||||
timestamp: now(),
|
||||
stats: { oldestDataTimestamp: now() },
|
||||
} as unknown as ChartsResponse;
|
||||
const map = extractInfrastructureSummaryChartMap(response);
|
||||
expect([...map.keys()]).toEqual(['agent-1']);
|
||||
expect(map.get('agent-1')?.cpu?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('exposes ChartsResponse.data verbatim because the extractor ignores that field', () => {
|
||||
// Documents a real-behaviour fact: data on the ChartsResponse is *not*
|
||||
// indexed, only nodeData/agentData/dockerHostData are. A direct call with
|
||||
// populated `data` and nothing else yields an empty map.
|
||||
const response = {
|
||||
data: { 'vm-1': { cpu: makeSeries(3) } },
|
||||
nodeData: {},
|
||||
storageData: {},
|
||||
timestamp: now(),
|
||||
stats: { oldestDataTimestamp: now() },
|
||||
} as unknown as ChartsResponse;
|
||||
expect(extractInfrastructureSummaryChartMap(response).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractInfrastructureSummaryChartMapFromInfrastructureResponse — uncovered optional-forwarding arms', () => {
|
||||
it('forwards an absent nodeData through the ?? and still extracts agent/docker data', () => {
|
||||
// The `response.nodeData ?? {}` expression's right operand is only reached
|
||||
// when nodeData is absent; the wrapper test only exercised populated nodeData.
|
||||
const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse(
|
||||
makeInfraResponse({
|
||||
nodeData: undefined,
|
||||
agentData: { 'agent-1': { memory: makeSeries(2) } },
|
||||
dockerHostData: { 'docker-1': { disk: makeSeries(2) } },
|
||||
}),
|
||||
);
|
||||
expect([...map.keys()].sort()).toEqual(['agent-1', 'docker-1']);
|
||||
expect(map.get('agent-1')?.memory?.length).toBe(2);
|
||||
expect(map.get('docker-1')?.disk?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readInfrastructureSummaryCache — series coercion coverage', () => {
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('coerces a non-array cpu field to an empty array (Array.isArray false arm for cpu)', () => {
|
||||
// The sibling test sets `cpu: makeSeries(1)` so the cpu cond-expr's false
|
||||
// arm stays open; here we store a non-array cpu and assert the coercion.
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({
|
||||
charts: {
|
||||
'node-1': {
|
||||
cpu: 'not-an-array',
|
||||
memory: [],
|
||||
disk: [],
|
||||
diskread: [],
|
||||
diskwrite: [],
|
||||
netin: [],
|
||||
netout: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const series = readInfrastructureSummaryCache('1h')?.map.get('node-1');
|
||||
expect(series?.cpu).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces a non-array netout field to an empty array (Array.isArray false arm for netout)', () => {
|
||||
// The sibling test sets `netout: []` which is still an array, leaving the
|
||||
// netout cond-expr false arm uncovered.
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({
|
||||
charts: {
|
||||
'node-1': {
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
diskread: [],
|
||||
diskwrite: [],
|
||||
netin: [],
|
||||
netout: { length: 5 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const series = readInfrastructureSummaryCache('1h')?.map.get('node-1');
|
||||
expect(series?.netout).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null oldestDataTimestamp for a non-finite numeric value (Infinity)', () => {
|
||||
// typeof Infinity === 'number' is true, so the only way to reach the null
|
||||
// fallback past the typeof guard is a number that fails Number.isFinite.
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({
|
||||
oldestDataTimestamp: Infinity as unknown as number,
|
||||
charts: undefined,
|
||||
}),
|
||||
);
|
||||
expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null oldestDataTimestamp for NaN', () => {
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({
|
||||
oldestDataTimestamp: NaN,
|
||||
charts: undefined,
|
||||
}),
|
||||
);
|
||||
expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to an empty entries list when charts is a non-object truthy value', () => {
|
||||
// `parsed.charts && typeof parsed.charts === 'object'`: a truthy non-object
|
||||
// (string) takes the && left-operand true / right-operand false path,
|
||||
// producing [] rather than Object.entries throwing.
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({ charts: 'not-an-object' as unknown as Record<string, never> }),
|
||||
);
|
||||
const cached = readInfrastructureSummaryCache('1h');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('treats a null charts field as the emptyentries branch', () => {
|
||||
// null && ... -> false -> [] arm. Distinct from undefined (also falsy) and
|
||||
// from the non-object truthy case above.
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({ charts: null as unknown as undefined }),
|
||||
);
|
||||
const cached = readInfrastructureSummaryCache('1h');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readInfrastructureSummaryCache — hit / miss / expiry / empty boundaries', () => {
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns a cache hit with the exact cachedAt timestamp on the happy path', () => {
|
||||
const cachedAt = now() - 1_000;
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({
|
||||
cachedAt,
|
||||
oldestDataTimestamp: now() - 5_000,
|
||||
charts: {
|
||||
'node-1': { cpu: makeSeries(2), memory: [], disk: [], diskread: [], diskwrite: [], netin: [], netout: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const hit = readInfrastructureSummaryCache('1h');
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit?.cachedAt).toBe(cachedAt);
|
||||
expect(hit?.oldestDataTimestamp).toBe(now() - 5_000);
|
||||
expect(hit?.map.get('node-1')?.cpu?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns a cache miss for a never-written key', () => {
|
||||
expect(readInfrastructureSummaryCache('1h')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a cache hit when age equals maxAgeMs exactly (strict > boundary)', () => {
|
||||
// `Date.now() - parsed.cachedAt > maxAgeMs` uses strict >, so an entry that
|
||||
// is exactly maxAgeMs old must still be a hit (boundary is inclusive).
|
||||
const maxAge = 60_000;
|
||||
const cachedAt = now() - maxAge;
|
||||
storePayload(
|
||||
'1h',
|
||||
buildPayload({ cachedAt, charts: undefined }),
|
||||
);
|
||||
expect(readInfrastructureSummaryCache('1h', maxAge)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty map (cache hit, but empty) when charts is absent', () => {
|
||||
storePayload('1h', buildPayload({ charts: undefined }));
|
||||
const hit = readInfrastructureSummaryCache('1h');
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit?.map.size).toBe(0);
|
||||
expect(hit?.oldestDataTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('returns early with null when window is undefined', () => {
|
||||
// Covers the `if (typeof window === 'undefined') return null;` true arm in
|
||||
// readInfrastructureSummaryCache.
|
||||
storePayload('1h', buildPayload({ charts: undefined }));
|
||||
const hit = withGlobalUndefined('window', () => readInfrastructureSummaryCache('1h'));
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistInfrastructureSummaryCache — write-boundary coverage', () => {
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns early without writing when window is undefined', () => {
|
||||
// Covers the `if (typeof window === 'undefined') return;` true arm.
|
||||
localStorage.setItem('sentinel', 'keep-me');
|
||||
withGlobalUndefined('window', () => {
|
||||
persistInfrastructureSummaryCache(
|
||||
'1h',
|
||||
new Map([['node-1', { cpu: makeSeries(2) }]]),
|
||||
now() - 60_000,
|
||||
);
|
||||
});
|
||||
// No infra cache key was created.
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k) keys.push(k);
|
||||
}
|
||||
expect(keys.some((k) => k.startsWith('pulse.infrastructureSummaryCharts.'))).toBe(false);
|
||||
expect(localStorage.getItem('sentinel')).toBe('keep-me');
|
||||
});
|
||||
|
||||
it('uses an explicit orgScope argument rather than falling back to getOrgID()', () => {
|
||||
// Covers the left operand of `orgScope ?? getOrgID()` in the persist path:
|
||||
// when orgScope is supplied, getOrgID() must NOT be consulted, so the entry
|
||||
// lands under the explicit scope even though the ambient org ID differs.
|
||||
setOrgID('ambient-org');
|
||||
persistInfrastructureSummaryCache(
|
||||
'1h',
|
||||
new Map([['node-x', { cpu: makeSeries(1) }]]),
|
||||
null,
|
||||
'explicit-scope',
|
||||
);
|
||||
expect(localStorage.getItem(cacheKeyForRange('1h', 'explicit-scope'))).not.toBeNull();
|
||||
// Nothing landed under the ambient org scope.
|
||||
expect(localStorage.getItem(cacheKeyForRange('1h', 'ambient-org'))).toBeNull();
|
||||
});
|
||||
|
||||
it('triggers the data.cpu ?? [] fallback in toCachedChartData (cpu field absent)', () => {
|
||||
// The sibling test always supplies cpu, so the `?? []` right operand for
|
||||
// cpu stays uncovered. Persist a ChartData without cpu and verify the
|
||||
// cached entry round-trips with cpu === [].
|
||||
persistInfrastructureSummaryCache(
|
||||
'1h',
|
||||
new Map([['node-1', { memory: makeSeries(2) }]]),
|
||||
null,
|
||||
);
|
||||
const series = readInfrastructureSummaryCache('1h')?.map.get('node-1');
|
||||
expect(series?.cpu).toEqual([]);
|
||||
expect(series?.memory?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countChartMapPoints — uncovered ?? 0 fallbacks via fetch', () => {
|
||||
beforeEach(() => {
|
||||
mockGetCharts.mockReset();
|
||||
__resetInfrastructureSummaryFetchesForTests();
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('counts zero points for an entry whose cpu field is absent (data.cpu?.length ?? 0)', () => {
|
||||
// The perf-log data object is built eagerly on every successful fetch, so
|
||||
// countChartMapPoints runs even with perf disabled. Supplying a node with
|
||||
// no cpu takes the `?? 0` right operand for the cpu line.
|
||||
mockGetCharts.mockResolvedValueOnce(
|
||||
makeInfraResponse({
|
||||
nodeData: { 'node-1': { memory: makeSeries(3) } },
|
||||
}),
|
||||
);
|
||||
// Assertion is on the observable round-trip: the fetch resolved and the
|
||||
// entry round-tripped through persist/read without dropping memory.
|
||||
return fetchInfrastructureSummaryAndCache('1h').then((result) => {
|
||||
expect(result.map.get('node-1')?.memory?.length).toBe(3);
|
||||
expect(result.map.get('node-1')?.cpu).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchInfrastructureSummaryAndCache — error and caller edge arms', () => {
|
||||
beforeEach(() => {
|
||||
mockGetCharts.mockReset();
|
||||
__resetInfrastructureSummaryFetchesForTests();
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('rethrows a non-Error rejection as-is after the String(error) fallback arm', () => {
|
||||
// `error instanceof Error ? error.message : String(error)`: a string throw
|
||||
// takes the false arm and String('boom') === 'boom' is fed to the perf log.
|
||||
mockGetCharts.mockRejectedValueOnce('boom');
|
||||
return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('boom');
|
||||
});
|
||||
|
||||
it('rethrows a numeric rejection (String(42) fallback shape)', () => {
|
||||
mockGetCharts.mockRejectedValueOnce(42);
|
||||
return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe(42);
|
||||
});
|
||||
|
||||
it("falls back to 'unknown' when options.caller is an empty string (|| right arm)", () => {
|
||||
// `options?.caller || 'unknown'`: empty string is falsy, so the right arm
|
||||
// is taken. The fetch still resolves and persists a cache entry, which is
|
||||
// the observable proof the call completed normally.
|
||||
mockGetCharts.mockResolvedValueOnce(makeInfraResponse());
|
||||
return fetchInfrastructureSummaryAndCache('1h', { caller: '' }).then(() => {
|
||||
expect(readInfrastructureSummaryCache('1h')).not.toBeNull();
|
||||
// Caller normalisation must not bleed into the persisted metrics list.
|
||||
expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, {
|
||||
metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a null metrics option through normalize -> default metrics list', () => {
|
||||
// `normalizeInfrastructureSummaryMetrics(null)`: Array.isArray(null) is
|
||||
// false, so the !Array.isArray arm returns the default list rather than
|
||||
// constructing from the (null) input.
|
||||
mockGetCharts.mockResolvedValueOnce(makeInfraResponse());
|
||||
return fetchInfrastructureSummaryAndCache('1h', { metrics: null }).then(() => {
|
||||
expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, {
|
||||
metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates a custom metric list (separate inFlightKey) from the default list', () => {
|
||||
// Two different metric lists produce two different inFlightKeys, so they
|
||||
// issue two distinct requests. This pins the inFlightKeyFor metrics
|
||||
// participation: changing the metrics list must NOT be deduped against the
|
||||
// default list.
|
||||
mockGetCharts.mockResolvedValueOnce(makeInfraResponse());
|
||||
mockGetCharts.mockResolvedValueOnce(makeInfraResponse());
|
||||
return Promise.all([
|
||||
fetchInfrastructureSummaryAndCache('1h', { metrics: ['cpu'] }),
|
||||
fetchInfrastructureSummaryAndCache('1h'),
|
||||
]).then(() => {
|
||||
expect(mockGetCharts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('infraSummaryPerfNow — Date.now() fallback arm', () => {
|
||||
beforeEach(() => {
|
||||
mockGetCharts.mockReset();
|
||||
__resetInfrastructureSummaryFetchesForTests();
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('still records a finite ms duration when performance is unavailable', async () => {
|
||||
// `typeof performance !== 'undefined' && typeof performance.now === 'function'`:
|
||||
// hiding performance takes the && false arm, so infraSummaryPerfNow falls
|
||||
// back to Date.now(). The fetch path calls perfNow at start and end, so a
|
||||
// successful round-trip with performance masked proves the fallback works.
|
||||
mockGetCharts.mockResolvedValueOnce(
|
||||
makeInfraResponse({ nodeData: { 'n': { cpu: makeSeries(1) } } }),
|
||||
);
|
||||
const result = await withGlobalUndefined('performance', () =>
|
||||
fetchInfrastructureSummaryAndCache('1h'),
|
||||
);
|
||||
expect(result.map.get('n')?.cpu?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('org_switched cache invalidation handler — uncovered getter + branches', () => {
|
||||
// The org_switched handler registered at module load (and its inner forEach
|
||||
// callback) are never invoked by the existing tests. Emitting the event here
|
||||
// exercises the whole invalidation closure: the `typeof window` guard, the
|
||||
// key/prefix filter, the keysToRemove forEach, and the catch-all.
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('removes every prefixed cache entry on org_switched and preserves unrelated keys', () => {
|
||||
const prefixedA = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`;
|
||||
const prefixedB = `${'pulse.infrastructureSummaryCharts.'}default::24h::cpu`;
|
||||
const unrelated = 'pulse.somethingElse.key';
|
||||
localStorage.setItem(prefixedA, 'a');
|
||||
localStorage.setItem(prefixedB, 'b');
|
||||
localStorage.setItem(unrelated, 'c');
|
||||
|
||||
eventBus.emit('org_switched', 'new-org');
|
||||
|
||||
expect(localStorage.getItem(prefixedA)).toBeNull();
|
||||
expect(localStorage.getItem(prefixedB)).toBeNull();
|
||||
expect(localStorage.getItem(unrelated)).toBe('c');
|
||||
});
|
||||
|
||||
it('skips keys for which localStorage.key returns null (key && ... false arm)', () => {
|
||||
// The && left operand is reachable only if a stored key is null; that never
|
||||
// happens with the in-memory storage used in tests, so mock key() to force
|
||||
// a null return and confirm the handler completes without pushing anything.
|
||||
const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`;
|
||||
localStorage.setItem(prefixed, 'a');
|
||||
// Stub Storage.prototype.key to return null for the first slot, which makes
|
||||
// the `key && key.startsWith(...)` short-circuit on the && left operand.
|
||||
const keySpy = vi.spyOn(Storage.prototype, 'key').mockReturnValue(null);
|
||||
try {
|
||||
expect(() => eventBus.emit('org_switched', 'newer-org')).not.toThrow();
|
||||
} finally {
|
||||
keySpy.mockRestore();
|
||||
}
|
||||
// Because key() returned null, the real prefixed entry was never matched
|
||||
// and is still present.
|
||||
expect(localStorage.getItem(prefixed)).toBe('a');
|
||||
});
|
||||
|
||||
it('no-ops when there are no prefixed entries to remove (empty keysToRemove)', () => {
|
||||
// Covers the forEach callback's "zero iterations" path: keysToRemove is
|
||||
// empty because no key starts with the prefix.
|
||||
const unrelated = 'completely.unrelated';
|
||||
localStorage.setItem(unrelated, 'keep');
|
||||
expect(() => eventBus.emit('org_switched', 'empty-org')).not.toThrow();
|
||||
expect(localStorage.getItem(unrelated)).toBe('keep');
|
||||
});
|
||||
|
||||
it('does not throw when the localStorage iteration itself throws (catch arm)', () => {
|
||||
// Covers the try/catch around the invalidation loop: if localStorage.length
|
||||
// throws, the handler must swallow it and return silently.
|
||||
const lengthSpy = vi
|
||||
.spyOn(Storage.prototype, 'length', 'get')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('storage poisoned');
|
||||
});
|
||||
try {
|
||||
expect(() => eventBus.emit('org_switched', 'broken-org')).not.toThrow();
|
||||
} finally {
|
||||
lengthSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns early without iterating when window is undefined (typeof window guard true arm)', () => {
|
||||
// Covers the `if (typeof window === 'undefined') return;` true arm of the
|
||||
// org_switched handler. A prefixed entry must survive because the handler
|
||||
// bails out before touching localStorage.
|
||||
const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`;
|
||||
localStorage.setItem(prefixed, 'survives');
|
||||
withGlobalUndefined('window', () => {
|
||||
expect(() => eventBus.emit('org_switched', 'no-window-org')).not.toThrow();
|
||||
});
|
||||
expect(localStorage.getItem(prefixed)).toBe('survives');
|
||||
});
|
||||
});
|
||||
|
||||
describe('infraSummaryPerfLog — perf-enabled branches via module reload', () => {
|
||||
// `infraSummaryPerfEnabled` is captured once at module load from
|
||||
// `import.meta.env.DEV && import.meta.env.VITE_INFRA_SUMMARY_PERF === '1'`.
|
||||
// Reaching the perf-log body therefore requires stubbing the env var AND
|
||||
// re-evaluating the module (vi.resetModules + dynamic import) so the constant
|
||||
// is re-read with the stub in place. The static top-level imports that every
|
||||
// other describe block uses keep pointing at the original (perf-disabled)
|
||||
// module instance, so this isolation does not leak.
|
||||
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('emits [InfraSummaryPerf] console.debug lines when VITE_INFRA_SUMMARY_PERF=1', async () => {
|
||||
vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1');
|
||||
vi.resetModules();
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
try {
|
||||
const mod = await import('@/utils/infrastructureSummaryCache');
|
||||
mod.__resetInfrastructureSummaryFetchesForTests();
|
||||
mockGetCharts.mockReset();
|
||||
mockGetCharts.mockResolvedValueOnce(
|
||||
makeInfraResponse({ nodeData: { 'n': { cpu: makeSeries(1) } } }),
|
||||
);
|
||||
await mod.fetchInfrastructureSummaryAndCache('1h', { caller: 'perf-enabled' });
|
||||
|
||||
const prefixLines = debugSpy.mock.calls
|
||||
.map((call) => (typeof call[0] === 'string' ? call[0] : ''))
|
||||
.filter((msg) => msg.startsWith('[InfraSummaryPerf]'));
|
||||
// fetch start, fetch done (data present -> truthy arm of `if (data)`).
|
||||
expect(prefixLines.some((m) => m.endsWith('fetch start'))).toBe(true);
|
||||
expect(prefixLines.some((m) => m.endsWith('fetch done'))).toBe(true);
|
||||
// The data object was forwarded as the second console.debug argument.
|
||||
const doneCall = debugSpy.mock.calls.find(
|
||||
([msg]) => typeof msg === 'string' && msg.endsWith('fetch done'),
|
||||
);
|
||||
expect(doneCall?.[1]).toMatchObject({ caller: 'perf-enabled', range: '1h' });
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs the fetch-error path with a stringified error when perf is enabled', async () => {
|
||||
// Covers the perf-log call inside .catch (still gated on perfEnabled) with
|
||||
// a non-Error rejection so `error instanceof Error ? ... : String(error)`
|
||||
// takes its String(error) arm under perf-enabled conditions.
|
||||
vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1');
|
||||
vi.resetModules();
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
try {
|
||||
const mod = await import('@/utils/infrastructureSummaryCache');
|
||||
mod.__resetInfrastructureSummaryFetchesForTests();
|
||||
mockGetCharts.mockReset();
|
||||
mockGetCharts.mockRejectedValueOnce('perf-boom');
|
||||
await expect(mod.fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('perf-boom');
|
||||
|
||||
const errorLine = debugSpy.mock.calls
|
||||
.map((call) => (typeof call[0] === 'string' ? call[0] : ''))
|
||||
.find((msg) => msg.endsWith('fetch error'));
|
||||
expect(errorLine).toBeDefined();
|
||||
const dataArg = debugSpy.mock.calls.find(
|
||||
([msg]) => typeof msg === 'string' && msg.endsWith('fetch error'),
|
||||
)?.[1];
|
||||
expect(dataArg).toMatchObject({ error: 'perf-boom' });
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('readInfrastructureSummaryCache — read failure path', () => {
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('swallows a localStorage.getItem failure and returns null (catch arm)', () => {
|
||||
// Covers the outer try/catch in readInfrastructureSummaryCache: a throw out
|
||||
// of getItem must be swallowed and surface as a null result.
|
||||
const getItemSpy = vi
|
||||
.spyOn(Storage.prototype, 'getItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('getItem exploded');
|
||||
});
|
||||
try {
|
||||
expect(readInfrastructureSummaryCache('1h')).toBeNull();
|
||||
} finally {
|
||||
getItemSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistInfrastructureSummaryCache — JSON.stringify failure path', () => {
|
||||
beforeEach(() => {
|
||||
setOrgID('default');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('swallows a JSON.stringify cycle error without throwing', () => {
|
||||
// Build a value that JSON.stringify cannot serialize (a self-referencing
|
||||
// object smuggled in as a ChartData field). The try/catch around the
|
||||
// persist body must swallow the TypeError.
|
||||
const cyclic = { cpu: [] as MetricPoint[] } as unknown as ChartData;
|
||||
(cyclic as { self: unknown }).self = cyclic;
|
||||
expect(() =>
|
||||
persistInfrastructureSummaryCache(
|
||||
'1h',
|
||||
new Map([['node-1', cyclic]]),
|
||||
null,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,958 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getContainerRuntimeBadgeForRuntime,
|
||||
getInfrastructurePlatformBadges,
|
||||
getInfrastructureSystemIdentityBadges,
|
||||
getInfrastructureSystemIdentitySortLabel,
|
||||
getInfrastructureSystemTitleBadges,
|
||||
getPlatformBadge,
|
||||
getSourceBadge,
|
||||
getTypeBadge,
|
||||
getUnifiedSourceBadges,
|
||||
dedupeResourceBadges,
|
||||
} from '@/utils/resourceBadgePresentation';
|
||||
|
||||
// Module-private helpers (`titleFromParts`, `withBadgeVersion`, `normalizeVersion`,
|
||||
// `buildUnifiedSourceBadges`, `deriveSourceKeysFromFacets`, `getKnownHostIdentitySource`,
|
||||
// `getHostIdentityAgentProfile`, `getHostIdentityPlatform`, `getAgentSystemIdentityBadge`,
|
||||
// `getDockerHostOsIdentityBadge`, `getAvailabilitySystemIdentityBadge`,
|
||||
// `getStorageSystemIdentityBadge`, `getStoragePlatformSource`, `getSystemSourceVersion`,
|
||||
// `getVersionedSourceBadge`, `proxmoxLxcDockerBadge`, `proxmoxLxcDockerVmid`,
|
||||
// `badgeIdentityLabels`) are exercised transitively through the exported entry points
|
||||
// below, asserting on their observable outputs.
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'host-1',
|
||||
displayName: 'host-1',
|
||||
platformId: 'host-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: 1,
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const agentOnly = (agent: Record<string, unknown>): Partial<Resource> => ({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: { sources: ['agent'], agent },
|
||||
});
|
||||
|
||||
describe('getPlatformBadge — branch coverage', () => {
|
||||
it('returns null when platformType is absent', () => {
|
||||
expect(getPlatformBadge()).toBeNull();
|
||||
expect(getPlatformBadge(undefined)).toBeNull();
|
||||
expect(getPlatformBadge('' as never)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the dedicated availability badge for the availability platform type', () => {
|
||||
const badge = getPlatformBadge('availability');
|
||||
expect(badge).toStrictEqual({
|
||||
label: 'Availability',
|
||||
classes:
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
|
||||
title: 'Availability',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders shared presentation badges for recognized platform types', () => {
|
||||
expect(getPlatformBadge('kubernetes')?.label).toBe('K8s');
|
||||
expect(getPlatformBadge('truenas')?.label).toBe('TrueNAS');
|
||||
expect(getPlatformBadge('vmware-vsphere')?.label).toBe('vSphere');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceBadge — branch coverage', () => {
|
||||
it('returns null when sourceType is absent', () => {
|
||||
expect(getSourceBadge(undefined)).toBeNull();
|
||||
expect(getSourceBadge('' as never)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the canonical badge for a known source type', () => {
|
||||
const badge = getSourceBadge('api');
|
||||
expect(badge?.label).toBe('API');
|
||||
expect(badge?.classes).toContain('bg-blue-100');
|
||||
expect(badge?.title).toBe('api');
|
||||
});
|
||||
|
||||
it('falls back to the raw label and neutral tone for an unrecognized source type', () => {
|
||||
const badge = getSourceBadge('custom-collector' as never);
|
||||
expect(badge?.label).toBe('custom-collector');
|
||||
expect(badge?.classes).toContain('bg-surface-alt');
|
||||
expect(badge?.title).toBe('custom-collector');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTypeBadge — branch coverage', () => {
|
||||
it('returns null when resourceType is absent', () => {
|
||||
expect(getTypeBadge(undefined)).toBeNull();
|
||||
expect(getTypeBadge('')).toBeNull();
|
||||
expect(getTypeBadge(null as unknown as string)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a canonical label for a known resource type', () => {
|
||||
expect(getTypeBadge('pbs')?.label).toBe('PBS');
|
||||
});
|
||||
|
||||
it('maps a legacy/external alias to its presentation', () => {
|
||||
expect(getTypeBadge('proxmox-lxc')?.label).toBe('LXC');
|
||||
});
|
||||
|
||||
it('falls back to a default presentation for an unrecognized type string', () => {
|
||||
const badge = getTypeBadge('totally-fake-type');
|
||||
expect(badge?.label).toBe('totally-fake-type');
|
||||
expect(badge?.title).toBe('totally-fake-type');
|
||||
expect(badge?.classes).toContain('bg-surface-alt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnifiedSourceBadges / normalizeUnifiedSourceKeys — branch coverage', () => {
|
||||
it('returns an empty array for nullish or empty input', () => {
|
||||
expect(getUnifiedSourceBadges(null)).toEqual([]);
|
||||
expect(getUnifiedSourceBadges(undefined)).toEqual([]);
|
||||
expect(getUnifiedSourceBadges([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops values that do not normalize to a known platform', () => {
|
||||
expect(getUnifiedSourceBadges(['totally-unknown-source'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps canonical aliases and dedupes to a single badge per platform', () => {
|
||||
expect(getUnifiedSourceBadges(['pve', 'proxmox', 'pbs', 'pmg']).map((b) => b.label)).toEqual([
|
||||
'PVE',
|
||||
'PBS',
|
||||
'PMG',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes vmware, k8s, and hyper-v aliases', () => {
|
||||
expect(getUnifiedSourceBadges(['vmware', 'k8s', 'hyper-v']).map((b) => b.label)).toEqual([
|
||||
'vSphere',
|
||||
'K8s',
|
||||
'Hyper-V',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the availability and generic source presentations', () => {
|
||||
expect(getUnifiedSourceBadges(['availability']).map((b) => b.label)).toEqual(['Availability']);
|
||||
expect(getUnifiedSourceBadges(['generic']).map((b) => b.label)).toEqual(['Generic']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfrastructurePlatformBadges — branch coverage', () => {
|
||||
it('returns an empty array when no source normalizes to a known platform', () => {
|
||||
expect(getInfrastructurePlatformBadges([])).toEqual([]);
|
||||
expect(getInfrastructurePlatformBadges(undefined)).toEqual([]);
|
||||
expect(getInfrastructurePlatformBadges(['totally-unknown-source'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a single source straight through', () => {
|
||||
expect(getInfrastructurePlatformBadges(['docker']).map((b) => b.label)).toEqual([
|
||||
'Docker / Podman',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every non-agent platform source when multiple platforms remain', () => {
|
||||
expect(getInfrastructurePlatformBadges(['docker', 'kubernetes']).map((b) => b.label)).toEqual([
|
||||
'Docker / Podman',
|
||||
'K8s',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerRuntimeBadgeForRuntime / getContainerRuntimeTone — branch coverage', () => {
|
||||
it('returns null for an empty or whitespace-only runtime', () => {
|
||||
expect(getContainerRuntimeBadgeForRuntime('')).toBeNull();
|
||||
expect(getContainerRuntimeBadgeForRuntime(' ')).toBeNull();
|
||||
expect(getContainerRuntimeBadgeForRuntime(null)).toBeNull();
|
||||
expect(getContainerRuntimeBadgeForRuntime(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes Docker casing before selecting the docker tone', () => {
|
||||
const badge = getContainerRuntimeBadgeForRuntime('DOCKER');
|
||||
expect(badge?.label).toBe('Docker');
|
||||
expect(badge?.title).toBe('Runtime: Docker');
|
||||
expect(badge?.classes).toContain('bg-sky-100');
|
||||
});
|
||||
|
||||
it('selects the podman tone for a Podman runtime', () => {
|
||||
const badge = getContainerRuntimeBadgeForRuntime('podman');
|
||||
expect(badge?.label).toBe('Podman');
|
||||
expect(badge?.title).toBe('Runtime: Podman');
|
||||
expect(badge?.classes).toContain('bg-violet-100');
|
||||
});
|
||||
|
||||
it('falls back to the neutral tone for an unrecognized runtime label', () => {
|
||||
const badge = getContainerRuntimeBadgeForRuntime('containerd');
|
||||
expect(badge?.label).toBe('containerd');
|
||||
expect(badge?.classes).toContain('bg-surface-alt');
|
||||
expect(badge?.classes).toContain('text-base-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeResourceBadges — branch coverage', () => {
|
||||
it('drops nullish entries, empty labels, and duplicate identities', () => {
|
||||
expect(
|
||||
dedupeResourceBadges([
|
||||
null,
|
||||
{ label: 'PVE', classes: 'c' },
|
||||
{ label: 'pve', classes: 'c' },
|
||||
{ label: ' ', classes: 'c' },
|
||||
undefined,
|
||||
]),
|
||||
).toEqual([{ label: 'PVE', classes: 'c' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('badgeIdentityLabels via getInfrastructureSystemTitleBadges — branch coverage', () => {
|
||||
it('treats a system badge with no title as its label-only identity (value ? ... : "" arm)', () => {
|
||||
// badgeIdentityLabels maps [badge.label, badge.title]; when title is undefined
|
||||
// the `(value ? normalizeBadgeIdentityLabel(value) : '')` ternary takes the ""
|
||||
// arm and the entry is dropped by filter(Boolean). The label identity survives,
|
||||
// so a source badge sharing it is deduped away.
|
||||
const result = getInfrastructureSystemTitleBadges(
|
||||
[{ label: 'PVE', classes: 'type-cls' }],
|
||||
[{ label: 'pve', classes: 'type-cls', title: 'pve' }],
|
||||
);
|
||||
expect(result).toEqual([{ label: 'PVE', classes: 'type-cls' }]);
|
||||
});
|
||||
|
||||
it('retains a source badge whose identity is absent from the system identity set', () => {
|
||||
const result = getInfrastructureSystemTitleBadges(
|
||||
[{ label: 'PVE', classes: 'type-cls' }],
|
||||
[{ label: 'K8s', classes: 'type-cls', title: 'K8s' }],
|
||||
);
|
||||
expect(result.map((b) => b.label)).toEqual(['PVE', 'K8s']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withBadgeVersion via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('strips the Proxmox pve-manager wrapper format down to the dotted version', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'debian',
|
||||
osName: 'Proxmox VE',
|
||||
osVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE 9.1.9']);
|
||||
});
|
||||
|
||||
it.each(['unknown', 'n/a', 'na', 'none', '-'] as const)(
|
||||
'treats sentinel version %s as empty so no suffix is appended',
|
||||
(sentinelVersion) => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'linux',
|
||||
osName: 'TrueNAS SCALE',
|
||||
osVersion: sentinelVersion,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the title unchanged when the version already appears in it (? title arm)', () => {
|
||||
// The agent OS-label branch builds a title containing the version; withBadgeVersion
|
||||
// then detects the version substring in the title and reuses it verbatim rather
|
||||
// than appending a duplicate.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'linux',
|
||||
osName: 'Ubuntu 24.04',
|
||||
osVersion: '24.04',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges[0]?.label).toBe('Ubuntu 24.04');
|
||||
expect(badges[0]?.title).toBe('Ubuntu 24.04 24.04');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentSystemIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('resolves an exact agent host profile id (exactProfile return arm)', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
hostProfile: 'unraid',
|
||||
osName: 'Unraid',
|
||||
osVersion: '7.3.0',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid 7.3.0']);
|
||||
});
|
||||
|
||||
it('falls back to the badge title in the tooltip when osName is empty (hostProfile branch || arm)', () => {
|
||||
// `titleFromParts(osName || badge?.title || profileFamily || label, osVersion)`:
|
||||
// empty osName makes the `osName ||` operand falsy so evaluation proceeds to
|
||||
// `badge?.title`, but the badge still resolves.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
hostProfile: 'unraid',
|
||||
osName: '',
|
||||
osVersion: '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid']);
|
||||
expect(badges[0]?.title).toBe('Unraid');
|
||||
});
|
||||
|
||||
it('derives a known source from a raw platform key via normalizeSourcePlatformKey', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'linux',
|
||||
osName: 'truenas',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
});
|
||||
|
||||
it('falls back to the badge title in the tooltip when osName is empty (knownSource branch || arm)', () => {
|
||||
// `titleFromParts(osName || badge.title, osVersion)`: no hostProfile, empty osName,
|
||||
// but the platform key alone resolves a known source.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'truenas',
|
||||
osName: '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
expect(badges[0]?.title).toBe('TrueNAS');
|
||||
});
|
||||
|
||||
it('uses the second identity value when the first does not match', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'unknown-os',
|
||||
osName: 'truenas',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
});
|
||||
|
||||
it('matches an agent host profile identity token in the osName', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'unknown-os',
|
||||
osName: 'my unraid server',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid']);
|
||||
});
|
||||
|
||||
it('matches a source platform display token in the osName', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'linux',
|
||||
osName: 'Synology DSM',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Synology']);
|
||||
});
|
||||
|
||||
it('derives the OS label from platform when osName is empty (osLabel branch)', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'freebsd',
|
||||
osName: '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['FreeBSD']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ubuntu 24.04.2 LTS', 'Ubuntu'],
|
||||
['Debian GNU/Linux 12', 'Debian'],
|
||||
['Rocky Linux 9.3', 'Rocky'],
|
||||
['Alpine Linux 3.19', 'Alpine'],
|
||||
['AlmaLinux 9.3', 'AlmaLinux'],
|
||||
['CentOS Stream 9', 'CentOS'],
|
||||
['RHEL 9.4', 'RHEL'],
|
||||
['Arch Linux', 'Arch'],
|
||||
['openSUSE Tumbleweed', 'openSUSE'],
|
||||
['SUSE Linux Enterprise', 'SUSE'],
|
||||
['macOS Sonoma 14.4', 'macOS'],
|
||||
['Windows Server 2022', 'Windows'],
|
||||
['FreeBSD 14.1', 'FreeBSD'],
|
||||
['Linux Mint 22', 'Linux'],
|
||||
] as const)('derives the OS label %s from osName via hostOsLabelPatterns', (osName, expected) => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'linux',
|
||||
osName,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual([expected]);
|
||||
});
|
||||
|
||||
it('excludes docker/generic from host identity and falls through to the platform badge', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: 'generic',
|
||||
osName: 'generic host',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Agent']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilitySystemIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
const endpoint = (
|
||||
availability: Record<string, unknown>,
|
||||
overrides: Partial<Resource> = {},
|
||||
): Resource =>
|
||||
makeResource({
|
||||
type: 'network-endpoint',
|
||||
platformType: 'generic',
|
||||
sourceType: 'api',
|
||||
sources: ['availability'],
|
||||
platformData: { sources: ['availability'], availability },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('maps the icmp protocol to an uppercased label', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
endpoint({ protocol: 'icmp', address: 'host-a' }),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['ICMP']);
|
||||
expect(badges[0]?.title).toBe('ICMP availability probe host-a');
|
||||
});
|
||||
|
||||
it('maps tcp with a port to a TCP label and appends the port to the title', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
endpoint({ protocol: 'tcp', address: 'host-a', port: 22 }),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TCP']);
|
||||
expect(badges[0]?.title).toBe('TCP availability probe host-a:22');
|
||||
});
|
||||
|
||||
it('drops the port suffix when the port is zero', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
endpoint({ protocol: 'tcp', address: 'host-a', port: 0 }),
|
||||
);
|
||||
expect(badges[0]?.title).toBe('TCP availability probe host-a');
|
||||
});
|
||||
|
||||
it('maps http and https to uppercased labels without a port when none is set', () => {
|
||||
const http = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'http' }));
|
||||
expect(http.map((b) => b.label)).toEqual(['HTTP']);
|
||||
expect(http[0]?.title).toBe('HTTP availability probe');
|
||||
const https = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'https' }));
|
||||
expect(https.map((b) => b.label)).toEqual(['HTTPS']);
|
||||
});
|
||||
|
||||
it('uppercases an unrecognized non-empty protocol', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'snmp' }));
|
||||
expect(badges.map((b) => b.label)).toEqual(['SNMP']);
|
||||
});
|
||||
|
||||
it('falls back to the Probe label when the protocol is empty', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(endpoint({ address: 'host-a' }));
|
||||
expect(badges.map((b) => b.label)).toEqual(['Probe']);
|
||||
expect(badges[0]?.title).toBe('Probe availability probe host-a');
|
||||
});
|
||||
|
||||
it('treats a raw availability source as an endpoint even without an availability facet', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'api',
|
||||
sources: ['availability'],
|
||||
platformData: { sources: ['availability'] },
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Probe']);
|
||||
expect(badges[0]?.title).toBe('Probe availability probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorageSystemIdentityBadge / getStoragePlatformSource via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('versions a TrueNAS storage identity and surfaces the topology in the title', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
platformData: {
|
||||
sources: ['truenas'],
|
||||
storage: { platform: 'truenas', type: 'zfs-pool' },
|
||||
truenas: { version: '24.10.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS 24.10.0']);
|
||||
expect(badges[0]?.title).toBe('TrueNAS zfs-pool 24.10.0');
|
||||
});
|
||||
|
||||
it('reads the storage platform from platformData.platform for a storage-typed resource', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
sources: ['pbs'],
|
||||
platformData: {
|
||||
sources: ['pbs'],
|
||||
storage: { type: 'pbs-datastore', topology: 'datastore' },
|
||||
platform: 'pbs',
|
||||
pbs: { version: 2 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PBS 2']);
|
||||
});
|
||||
|
||||
it('resolves the docker version arm when the storage platform is docker', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
sources: ['docker'],
|
||||
platformData: {
|
||||
sources: ['docker'],
|
||||
storage: { platform: 'docker', type: 'docker-volume' },
|
||||
docker: { runtimeVersion: '24.0.7' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman 24.0.7']);
|
||||
});
|
||||
|
||||
it('resolves the unraid storage version from the agent osVersion', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { platform: 'unraid' },
|
||||
agent: { hostProfile: 'unraid', osVersion: '7.0.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid 7.0.0']);
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized storage platform and falls through', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { platform: 'mystery-nas', type: 'mystery-array' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Agent']);
|
||||
});
|
||||
|
||||
it('skips platformData.platform for a non-storage resource type (ternary "" arm)', () => {
|
||||
// An agent-shaped resource carrying a storage facet with no platform: the ternary
|
||||
// takes the "" arm (type is 'agent') so storagePlatform resolves to null and the
|
||||
// storage identity badge is skipped, letting the caller fall through.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { type: 'mystery-array' },
|
||||
platform: 'should-be-ignored',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Agent']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemSourceVersion via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('versions PBS, PMG, vSphere, and K8s identities from their facet versions', () => {
|
||||
const pbs = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'pbs',
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
sources: ['pbs'],
|
||||
platformData: { sources: ['pbs'], pbs: { version: '3.3.2' } },
|
||||
}),
|
||||
);
|
||||
expect(pbs.map((b) => b.label)).toEqual(['PBS 3.3.2']);
|
||||
|
||||
const vsphere = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'vm',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware'],
|
||||
platformData: { sources: ['vmware'], vmware: { version: '8.0.3' } },
|
||||
}),
|
||||
);
|
||||
expect(vsphere.map((b) => b.label)).toEqual(['vSphere 8.0.3']);
|
||||
|
||||
const k8s = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'k8s-cluster',
|
||||
platformType: 'kubernetes',
|
||||
sourceType: 'api',
|
||||
sources: ['kubernetes'],
|
||||
platformData: { sources: ['kubernetes'], kubernetes: { version: '1.31.0' } },
|
||||
}),
|
||||
);
|
||||
expect(k8s.map((b) => b.label)).toEqual(['K8s 1.31.0']);
|
||||
});
|
||||
|
||||
it('falls back to platformData.version for proxmox-pmg when the pmg facet omits version', () => {
|
||||
// PMG case: `getRecordVersion(pmg, 'version') || getRecordVersion(platformData, 'version')`
|
||||
// — the pmg facet has no version so the `||` evaluates the right operand.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'pmg',
|
||||
platformType: 'proxmox-pmg',
|
||||
sourceType: 'api',
|
||||
sources: ['pmg'],
|
||||
platformData: {
|
||||
sources: ['pmg'],
|
||||
pmg: { hostname: 'mailgw-1' },
|
||||
version: '9.1.2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PMG 9.1.2']);
|
||||
});
|
||||
|
||||
it('falls back to the platform facet version in the default case when the source facet omits it', () => {
|
||||
// Default case: `getRecordVersion(sourceRecord, ...) || getRecordVersion(platformRecord, ...)`.
|
||||
// A presentation-only platform (synology-dsm) routes here; its own facet lacks a
|
||||
// version, so the `||` evaluates the platform-record operand.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'api',
|
||||
sources: ['synology-dsm'],
|
||||
platformData: {
|
||||
sources: ['synology-dsm'],
|
||||
'synology-dsm': { note: 'no version here' },
|
||||
platform: { version: '7.2.1' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Synology 7.2.1']);
|
||||
});
|
||||
|
||||
it('drops the version suffix when neither the source facet nor the platform facet carries one', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'api',
|
||||
sources: ['microsoft-hyperv'],
|
||||
platformData: {
|
||||
sources: ['microsoft-hyperv'],
|
||||
'microsoft-hyperv': { note: 'no version here' },
|
||||
platform: { note: 'also no version' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Hyper-V']);
|
||||
});
|
||||
|
||||
it('falls back to platformData.pveVersion for proxmox-pve when the proxmox facet omits it', () => {
|
||||
// PVE case: `getRecordVersion(proxmox,'pveVersion','version') || getRecordVersion(platformData,'pveVersion')`
|
||||
// — the proxmox facet has no version so the `||` evaluates the platformData operand.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
sources: ['proxmox'],
|
||||
platformData: {
|
||||
sources: ['proxmox'],
|
||||
proxmox: { hostname: 'pve-1' },
|
||||
pveVersion: '8.2.4',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE 8.2.4']);
|
||||
});
|
||||
|
||||
it('falls back to the agent osVersion for proxmox-pve when no platform version is present', () => {
|
||||
// PVE case third operand: both version records are empty, so the `||` evaluates
|
||||
// getAgentPlatformVersion, which proceeds because the agent identity matches the
|
||||
// resolved source.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
sources: ['proxmox'],
|
||||
platformData: {
|
||||
sources: ['proxmox'],
|
||||
proxmox: { hostname: 'pve-1' },
|
||||
agent: { platform: 'proxmox-pve', osVersion: '8.2.4' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE 8.2.4']);
|
||||
});
|
||||
|
||||
it('falls back to platformData.version for proxmox-pbs when the pbs facet omits version', () => {
|
||||
// PBS case: `getRecordVersion(pbs, 'version') || getRecordVersion(platformData, 'version')`.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'pbs',
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
sources: ['pbs'],
|
||||
platformData: {
|
||||
sources: ['pbs'],
|
||||
pbs: { hostname: 'pbs-1' },
|
||||
version: '3.3.2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PBS 3.3.2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHostIdentityAgentProfile / getAgentIdentitySource via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('resolves an exact agent host profile id passed as the osName (exactProfile return arm)', () => {
|
||||
// getKnownHostIdentitySource feeds 'unraid' to getHostIdentityAgentProfile, which
|
||||
// resolves an exact manifest entry and returns it before the token-search fallback.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource(
|
||||
agentOnly({
|
||||
platform: '',
|
||||
osName: 'unraid',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid']);
|
||||
});
|
||||
|
||||
it('skips a non-agent-profile hostProfile and falls back to platformData.osVersion for unraid', () => {
|
||||
// getAgentIdentitySource sees hostProfile 'proxmox-pve' (not an agent host profile),
|
||||
// so the `if (profile && getSourcePlatformManifestEntry(profile.id))` guard is false
|
||||
// and it falls through; getAgentPlatformVersion then returns '' for the 'unraid'
|
||||
// source because the resolved identity differs, so the unraid case falls back to
|
||||
// platformData.osVersion.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { platform: 'unraid' },
|
||||
agent: { hostProfile: 'proxmox-pve', platform: 'linux' },
|
||||
osVersion: '6.9.0',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid 6.9.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSourceKeysFromFacets via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('derives the system source from a proxmox facet when no explicit sources are set', () => {
|
||||
// resource.sources is absent, so rawSources is built from platformData.sources
|
||||
// plus deriveSourceKeysFromFacets; the proxmox facet pushes 'proxmox' which then
|
||||
// resolves the PVE version from pveVersion.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
platformData: {
|
||||
sources: [],
|
||||
proxmox: { pveVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE 9.1.9']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerHostOsIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
const dockerHost = (docker: Resource['docker']): Resource =>
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
sources: ['docker'],
|
||||
platformData: { sources: ['docker'] },
|
||||
docker,
|
||||
});
|
||||
|
||||
it('maps a docker OS string that matches a governed platform to the shared badge', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
dockerHost({ os: 'TrueNAS SCALE', runtime: 'docker' }),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
});
|
||||
|
||||
it('derives a host OS label from docker metadata when no known source matches', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
dockerHost({ os: 'Ubuntu 24.04.2 LTS', runtime: 'docker' }),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Ubuntu']);
|
||||
expect(badges[0]?.title).toBe('Ubuntu 24.04.2 LTS');
|
||||
});
|
||||
|
||||
it('falls back to the docker runtime badge when the docker OS string is unrecognized', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
dockerHost({ os: 'acme-custom-os', runtime: 'docker' }),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxmoxLxcDockerBadge / proxmoxLxcDockerVmid via getInfrastructureSystemIdentityBadges — branch coverage', () => {
|
||||
it('surfaces the trailing VMID when the host source id has multiple colon segments', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
docker: {
|
||||
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:250',
|
||||
hostname: 'svc',
|
||||
runtime: 'docker',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['LXC']);
|
||||
expect(badges[0]?.title).toBe('Docker running inside Proxmox LXC 250');
|
||||
});
|
||||
|
||||
it('omits the VMID from the tooltip when the trailing segment is zero', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
docker: {
|
||||
hostSourceId: 'proxmox-lxc-docker:0',
|
||||
hostname: 'svc',
|
||||
runtime: 'docker',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['LXC']);
|
||||
expect(badges[0]?.title).toBe('Docker running inside a Proxmox LXC');
|
||||
});
|
||||
|
||||
it('produces no LXC badge when docker.hostSourceId is a non-LXC string (startsWith false arm)', () => {
|
||||
// A string hostSourceId that is not the LXC prefix takes the early-return arm, so
|
||||
// no LXC badge is emitted and the docker runtime badge is used instead.
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
sources: ['docker'],
|
||||
docker: { hostSourceId: 'docker://box-01', runtime: 'docker' },
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfrastructureSystemIdentityBadges — end-to-end branch coverage', () => {
|
||||
it('falls back to the platform badge for a bare agent resource with no other identity', () => {
|
||||
const resource = makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: [],
|
||||
});
|
||||
expect(getInfrastructureSystemIdentityBadges(resource).map((b) => b.label)).toEqual(['Agent']);
|
||||
});
|
||||
|
||||
it('returns an empty badge set when nothing resolves', () => {
|
||||
const resource = makeResource({
|
||||
id: 'lonely',
|
||||
name: 'lonely',
|
||||
type: 'vm',
|
||||
platformType: undefined,
|
||||
sourceType: 'api',
|
||||
sources: [],
|
||||
});
|
||||
expect(getInfrastructureSystemIdentityBadges(resource)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfrastructureSystemIdentitySortLabel — branch coverage', () => {
|
||||
it('returns the first identity badge label when one resolves', () => {
|
||||
const resource = makeResource({
|
||||
type: 'k8s-node',
|
||||
platformType: 'kubernetes',
|
||||
sourceType: 'api',
|
||||
sources: ['kubernetes'],
|
||||
platformData: { sources: ['kubernetes'], kubernetes: { version: '1.28.4' } },
|
||||
});
|
||||
expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('K8s 1.28.4');
|
||||
});
|
||||
|
||||
it('returns the platform badge label when no system identity is found', () => {
|
||||
const resource = makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'agent',
|
||||
platformData: { sources: ['agent'] },
|
||||
});
|
||||
expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('TrueNAS');
|
||||
});
|
||||
|
||||
it('returns "" when no identity, no platform badge, and no platformType resolve', () => {
|
||||
const resource = makeResource({
|
||||
type: 'storage',
|
||||
platformType: '' as Resource['platformType'],
|
||||
sourceType: 'agent',
|
||||
sources: [],
|
||||
});
|
||||
expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,756 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MetricPoint, TimeRange, WorkloadChartsResponse } from '@/api/charts';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import {
|
||||
__resetWorkloadsSummaryCacheForTests,
|
||||
fetchWorkloadsSummaryAndCache,
|
||||
hasFreshWorkloadsSummaryCache,
|
||||
persistWorkloadsSummaryCache,
|
||||
readInMemoryWorkloadsSummaryCache,
|
||||
readWorkloadsSummaryCache,
|
||||
workloadsSummaryCacheScopeKey,
|
||||
} from '@/utils/workloadsSummaryCache';
|
||||
|
||||
// Module-private helpers (`normalizeNodeScope`, `normalizeMaxPoints`,
|
||||
// `resolveOrgScope`, `storageCacheKeyFor`, `trimPoints`, `toCachedChartData`,
|
||||
// `writeInMemoryWorkloadsSummaryCache`) are not exported, so they are exercised
|
||||
// transitively through the exported entry points below, asserting on their
|
||||
// observable outputs (cache keys, API call args, trimmed persisted arrays).
|
||||
|
||||
const mockGetWorkloadCharts = vi.fn();
|
||||
|
||||
vi.mock('@/api/charts', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/charts')>('@/api/charts');
|
||||
return {
|
||||
...actual,
|
||||
ChartsAPI: {
|
||||
...actual.ChartsAPI,
|
||||
getWorkloadCharts: (...args: unknown[]) => mockGetWorkloadCharts(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// `getOrgID` is the fallback inside `resolveOrgScope` (`orgScope ?? getOrgID()`).
|
||||
// Mocking it to a sentinel lets us pin the nullish-orgScope arm deterministically.
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
getOrgID: () => 'test-org',
|
||||
}));
|
||||
|
||||
// NOTE: `@/stores/events` is deliberately NOT mocked so the module's real
|
||||
// `eventBus.on('org_switched', ...)` side-effect registers, letting us exercise
|
||||
// the org-switch cache-invalidation handler by emitting the event.
|
||||
|
||||
const ORG_FROM_GETORGID = 'test-org';
|
||||
const VERSION = 6;
|
||||
const PREFIX = 'pulse.workloadsSummaryCharts.';
|
||||
|
||||
// Mirrors the module's private `resolveOrgScope` (with the mocked getOrgID) for
|
||||
// setup-only key construction; assertions are on observable values, never on
|
||||
// this helper's output.
|
||||
const resolveOrgScopeForSetup = (orgScope?: string | null): string => {
|
||||
const raw = orgScope ?? ORG_FROM_GETORGID;
|
||||
const trimmed = (raw || '').trim();
|
||||
return trimmed || 'default';
|
||||
};
|
||||
|
||||
const storageKeyFor = (
|
||||
range: TimeRange,
|
||||
normalizedNodeScope: string,
|
||||
orgScope?: string | null,
|
||||
): string =>
|
||||
`${PREFIX}${encodeURIComponent(resolveOrgScopeForSetup(orgScope))}::${range}::${encodeURIComponent(normalizedNodeScope || '__all__')}`;
|
||||
|
||||
const metric = (value: number): MetricPoint => ({ timestamp: value, value });
|
||||
|
||||
const points = (n: number): MetricPoint[] =>
|
||||
Array.from({ length: n }, (_, i) => metric(i));
|
||||
|
||||
const makeResponse = (cpu?: MetricPoint[]): WorkloadChartsResponse => ({
|
||||
data: {
|
||||
w1: {
|
||||
cpu: cpu ?? [metric(1), metric(2)],
|
||||
memory: [metric(3), metric(4)],
|
||||
disk: [metric(5), metric(6)],
|
||||
diskread: [metric(7), metric(8)],
|
||||
diskwrite: [metric(9), metric(10)],
|
||||
netin: [metric(11), metric(12)],
|
||||
netout: [metric(13), metric(14)],
|
||||
},
|
||||
},
|
||||
dockerData: {},
|
||||
guestTypes: {},
|
||||
timestamp: 1,
|
||||
stats: { oldestDataTimestamp: 1 },
|
||||
});
|
||||
|
||||
const emptyResponse = (): WorkloadChartsResponse => ({
|
||||
data: {},
|
||||
dockerData: {},
|
||||
guestTypes: {},
|
||||
timestamp: 1,
|
||||
stats: { oldestDataTimestamp: 0 },
|
||||
});
|
||||
|
||||
describe('workloadsSummaryCache branch coverage (supplemental)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(emptyResponse());
|
||||
__resetWorkloadsSummaryCacheForTests();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('normalizeNodeScope (via workloadsSummaryCacheScopeKey)', () => {
|
||||
it('trims surrounding whitespace from a defined node scope', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', ' node-1 ', 'acme')).toBe(
|
||||
`${VERSION}::acme::1h::node-1`,
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces undefined node scope to empty string via the ?. ?? chain', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', undefined, 'acme')).toBe(
|
||||
`${VERSION}::acme::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces null node scope to empty string (optional chaining treats null as nullish)', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', null, 'acme')).toBe(
|
||||
`${VERSION}::acme::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses a whitespace-only node scope to empty string', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', ' ', 'acme')).toBe(
|
||||
`${VERSION}::acme::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the defaulted params when only range is supplied', () => {
|
||||
// nodeScope defaults '' and orgScope defaults undefined -> getOrgID().
|
||||
expect(workloadsSummaryCacheScopeKey('1h')).toBe(
|
||||
`${VERSION}::${ORG_FROM_GETORGID}::1h::`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrgScope (via workloadsSummaryCacheScopeKey)', () => {
|
||||
it('honours an explicitly provided org scope without calling getOrgID', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', '', 'custom')).toBe(
|
||||
`${VERSION}::custom::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to getOrgID() when orgScope is undefined (?? right operand)', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', '', undefined)).toBe(
|
||||
`${VERSION}::${ORG_FROM_GETORGID}::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to getOrgID() when orgScope is null (?? right operand)', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', '', null)).toBe(
|
||||
`${VERSION}::${ORG_FROM_GETORGID}::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('normalises an empty-string orgScope to the default org (empty string is not nullish)', () => {
|
||||
// '' ?? getOrgID() -> '' (not nullish), then normalizeOrgScope('') -> 'default'.
|
||||
expect(workloadsSummaryCacheScopeKey('1h', '', '')).toBe(
|
||||
`${VERSION}::default::1h::`,
|
||||
);
|
||||
});
|
||||
|
||||
it('trims and keeps a whitespace-padded org scope', () => {
|
||||
expect(workloadsSummaryCacheScopeKey('1h', '', ' acme ')).toBe(
|
||||
`${VERSION}::acme::1h::`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageCacheKeyFor (observable via persisted localStorage key)', () => {
|
||||
it('substitutes "__all__" when nodeScope is empty (|| arm false)', () => {
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse());
|
||||
expect(localStorage.getItem(`${PREFIX}acme::1h::__all__`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the concrete node scope when it is non-empty (|| arm true)', () => {
|
||||
persistWorkloadsSummaryCache('1h', 'node-1', 'acme', makeResponse());
|
||||
expect(localStorage.getItem(`${PREFIX}acme::1h::node-1`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('URL-encodes special characters in both org scope and node scope', () => {
|
||||
persistWorkloadsSummaryCache('1h', 'a/b', 'a b', makeResponse());
|
||||
expect(localStorage.getItem(`${PREFIX}a%20b::1h::a%2Fb`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMaxPoints (via fetchWorkloadsSummaryAndCache call args)', () => {
|
||||
const expectCall = async (
|
||||
maxPoints: unknown,
|
||||
expected: number | undefined,
|
||||
): Promise<void> => {
|
||||
await fetchWorkloadsSummaryAndCache('1h', {
|
||||
maxPoints: maxPoints as number | null,
|
||||
});
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, {
|
||||
nodeId: undefined,
|
||||
maxPoints: expected,
|
||||
});
|
||||
};
|
||||
|
||||
it('rounds a positive fractional maxPoints to the nearest integer', async () => {
|
||||
await expectCall(180.7, 181);
|
||||
});
|
||||
|
||||
it('passes a positive integer maxPoints through unchanged', async () => {
|
||||
await expectCall(360, 360);
|
||||
});
|
||||
|
||||
it('returns undefined for a non-number type (string)', async () => {
|
||||
await expectCall('big', undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for null', async () => {
|
||||
await expectCall(null, undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for NaN (non-finite)', async () => {
|
||||
await expectCall(NaN, undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for Infinity (non-finite)', async () => {
|
||||
await expectCall(Infinity, undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for zero (maxPoints <= 0 guard)', async () => {
|
||||
await expectCall(0, undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for a negative number (maxPoints <= 0 guard)', async () => {
|
||||
await expectCall(-5, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeNodeScope via fetch (nodeId arg normalization)', () => {
|
||||
it('passes the trimmed nodeId to the API', async () => {
|
||||
await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' node-1 ' });
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, {
|
||||
nodeId: 'node-1',
|
||||
maxPoints: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('converts an empty/whitespace nodeId to undefined (nodeScope || undefined)', async () => {
|
||||
await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' ' });
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, {
|
||||
nodeId: undefined,
|
||||
maxPoints: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('converts a null nodeId to undefined', async () => {
|
||||
await fetchWorkloadsSummaryAndCache('1h', { nodeId: null });
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, {
|
||||
nodeId: undefined,
|
||||
maxPoints: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrgScope via fetch (org routing)', () => {
|
||||
it('persists under the explicitly provided org scope', async () => {
|
||||
const response = makeResponse();
|
||||
mockGetWorkloadCharts.mockResolvedValueOnce(response);
|
||||
await fetchWorkloadsSummaryAndCache('1h', { orgScope: 'acme' });
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response);
|
||||
});
|
||||
|
||||
it('persists under getOrgID() when orgScope is omitted', async () => {
|
||||
const response = makeResponse();
|
||||
mockGetWorkloadCharts.mockResolvedValueOnce(response);
|
||||
await fetchWorkloadsSummaryAndCache('1h', {});
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', ORG_FROM_GETORGID)).toBe(
|
||||
response,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimPoints (via persisted+read storage arrays)', () => {
|
||||
const persistAndReadCpu = async (cpu: MetricPoint[] | undefined) => {
|
||||
const response: WorkloadChartsResponse = {
|
||||
data: { w1: { cpu } },
|
||||
dockerData: {},
|
||||
guestTypes: {},
|
||||
timestamp: 1,
|
||||
stats: { oldestDataTimestamp: 0 },
|
||||
};
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', response);
|
||||
const read = readWorkloadsSummaryCache('1h', '', 'acme');
|
||||
return read?.data.w1?.cpu ?? null;
|
||||
};
|
||||
|
||||
it('returns [] when the points array is empty', async () => {
|
||||
expect(await persistAndReadCpu([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when the points array is undefined (!points arm)', async () => {
|
||||
expect(await persistAndReadCpu(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a short array unchanged (length <= max arm)', async () => {
|
||||
const cpu = await persistAndReadCpu([metric(10), metric(20)]);
|
||||
expect(cpu).toEqual([metric(10), metric(20)]);
|
||||
});
|
||||
|
||||
it('downsamples 361 points without appending/slicing (step loop, no last-append, no final trim)', async () => {
|
||||
// step=ceil(361/360)=2 -> 181 samples; last sampled index == sliced last -> no append;
|
||||
// 181 <= 360 -> returned as-is.
|
||||
const cpu = await persistAndReadCpu(points(361));
|
||||
expect(cpu?.length).toBe(181);
|
||||
expect(cpu?.[0]).toEqual(metric(0));
|
||||
expect(cpu?.[180]).toEqual(metric(360));
|
||||
});
|
||||
|
||||
it('appends the final point when the step loop misses it (last-append arm, no final trim)', async () => {
|
||||
// 362 points -> step 2 -> 181 sampled; last sampled (360) != sliced last (361) -> push;
|
||||
// 182 <= 360 -> returned as-is.
|
||||
const cpu = await persistAndReadCpu(points(362));
|
||||
expect(cpu?.length).toBe(182);
|
||||
expect(cpu?.[0]).toEqual(metric(0));
|
||||
expect(cpu?.[181]).toEqual(metric(361));
|
||||
});
|
||||
|
||||
it('trims back to exactly max when downsample+append overflows (final slice arm)', async () => {
|
||||
// 720 points -> step 2 -> 360 sampled + 1 appended = 361 -> slice(1) -> 360.
|
||||
const cpu = await persistAndReadCpu(points(720));
|
||||
expect(cpu?.length).toBe(360);
|
||||
expect(cpu?.[0]).toEqual(metric(2));
|
||||
expect(cpu?.[359]).toEqual(metric(719));
|
||||
});
|
||||
|
||||
it('trims a large array (1000 pts) down to max keeping the most recent tail', async () => {
|
||||
const cpu = await persistAndReadCpu(points(1000));
|
||||
expect(cpu?.length).toBe(360);
|
||||
expect(cpu?.[cpu.length - 1]).toEqual(metric(999));
|
||||
});
|
||||
});
|
||||
|
||||
describe('readInMemoryWorkloadsSummaryCache', () => {
|
||||
it('returns null on a miss (?? null arm)', () => {
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the stored response on a hit', () => {
|
||||
const response = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', 'n1', 'acme', response);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response);
|
||||
});
|
||||
|
||||
it('returns null when the org scope differs', () => {
|
||||
const response = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', 'n1', 'acme', response);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'other')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the default-omitted orgScope through getOrgID()', () => {
|
||||
const response = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', 'n1', undefined, response);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1')).toBe(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeInMemoryWorkloadsSummaryCache eviction', () => {
|
||||
it('evicts the oldest entry once the 20-entry cap is exceeded', () => {
|
||||
const first = makeResponse([metric(1)]);
|
||||
persistWorkloadsSummaryCache('1h', 'node-0', 'acme', first);
|
||||
// Fill distinct scopes up to the cap.
|
||||
for (let i = 1; i < 20; i += 1) {
|
||||
persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', makeResponse());
|
||||
}
|
||||
// All 20 still resident.
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(first);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).not.toBeNull();
|
||||
|
||||
// 21st distinct scope triggers eviction of the oldest (node-0).
|
||||
const twentyFirst = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', 'node-20', 'acme', twentyFirst);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBeNull();
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-20', 'acme')).toBe(twentyFirst);
|
||||
});
|
||||
|
||||
it('overwrites in place without evicting when the scope key already exists', () => {
|
||||
const a = makeResponse([metric(1)]);
|
||||
const b = makeResponse([metric(2)]);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', a);
|
||||
}
|
||||
// Re-persist an existing key -> has() is true so no eviction; value updates.
|
||||
persistWorkloadsSummaryCache('1h', 'node-0', 'acme', b);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(b);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistWorkloadsSummaryCache error / edge arms', () => {
|
||||
it('skips localStorage entirely when window is undefined (SSR guard) but still warms memory', () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse());
|
||||
expect(localStorage.length).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
// Memory was written before the SSR guard.
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stores an empty data record when response.data is undefined (|| {} arm)', () => {
|
||||
persistWorkloadsSummaryCache(
|
||||
'1h',
|
||||
'',
|
||||
'acme',
|
||||
{ dockerData: {}, timestamp: 1, stats: { oldestDataTimestamp: 0 } } as unknown as WorkloadChartsResponse,
|
||||
);
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')?.data).toEqual({});
|
||||
});
|
||||
|
||||
it('stores an empty dockerData record when response.dockerData is undefined (|| {} arm)', () => {
|
||||
persistWorkloadsSummaryCache(
|
||||
'1h',
|
||||
'',
|
||||
'acme',
|
||||
{ data: {}, timestamp: 1, stats: { oldestDataTimestamp: 0 } } as unknown as WorkloadChartsResponse,
|
||||
);
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData).toEqual({});
|
||||
});
|
||||
|
||||
it('persists dockerData entries through toCachedChartData', () => {
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', {
|
||||
data: {},
|
||||
dockerData: { c1: { cpu: [metric(1), metric(2)] } },
|
||||
timestamp: 1,
|
||||
stats: { oldestDataTimestamp: 0 },
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData?.c1?.cpu).toEqual([
|
||||
metric(1),
|
||||
metric(2),
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the localStorage entry when the serialized payload exceeds the char cap', () => {
|
||||
// A single workload id whose key name alone pushes serialization past
|
||||
// WORKLOADS_SUMMARY_CACHE_MAX_CHARS (900_000) -> removeItem + early return.
|
||||
const hugeId = 'x'.repeat(1_000_000);
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', {
|
||||
data: { [hugeId]: { cpu: [metric(1)] } },
|
||||
dockerData: {},
|
||||
timestamp: 1,
|
||||
stats: { oldestDataTimestamp: 0 },
|
||||
});
|
||||
expect(localStorage.length).toBe(0);
|
||||
// Memory is populated before the size check.
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('swallows a localStorage.setItem failure (catch arm) without throwing', () => {
|
||||
const spy = vi
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
const response = makeResponse();
|
||||
expect(() =>
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', response),
|
||||
).not.toThrow();
|
||||
// Not persisted to storage (setItem threw)...
|
||||
expect(localStorage.length).toBe(0);
|
||||
// ...but memory was warmed before the try block.
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readWorkloadsSummaryCache', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns null on the SSR path (window undefined) without touching storage', () => {
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse());
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
// Storage entry left intact (read returned before touching localStorage).
|
||||
expect(localStorage.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when no entry exists (!raw arm)', () => {
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when JSON.parse throws (catch arm); leaves the malformed entry in place', () => {
|
||||
// The catch arm only swallows and returns null; it does NOT removeItem.
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
localStorage.setItem(key, '{ not valid json');
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBe('{ not valid json');
|
||||
});
|
||||
|
||||
const writePayload = (
|
||||
range: TimeRange,
|
||||
nodeScope: string,
|
||||
orgScope: string | undefined,
|
||||
payload: Record<string, unknown>,
|
||||
): void => {
|
||||
const normalizedNode = (nodeScope?.trim() ?? '');
|
||||
localStorage.setItem(
|
||||
storageKeyFor(range, normalizedNode, orgScope),
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
};
|
||||
|
||||
it('rejects and removes an entry whose version mismatches', () => {
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: 999,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: Date.now(),
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects and removes an entry whose range mismatches', () => {
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '4h',
|
||||
nodeScope: '',
|
||||
cachedAt: Date.now(),
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects and removes an entry whose nodeScope mismatches', () => {
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: 'different',
|
||||
cachedAt: Date.now(),
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects and removes an entry whose cachedAt is not a number', () => {
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: 'not-a-number',
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects and removes a stale entry past the 5-minute TTL', () => {
|
||||
const key = storageKeyFor('1h', '', 'acme');
|
||||
const stale = Date.now() - (5 * 60_000 + 1);
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: stale,
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a fresh entry exactly at the TTL boundary (age == max age)', () => {
|
||||
const now = 10_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: now - 5 * 60_000,
|
||||
data: { w1: { cpu: [metric(7)] } },
|
||||
dockerData: {},
|
||||
});
|
||||
const read = readWorkloadsSummaryCache('1h', '', 'acme');
|
||||
expect(read?.data.w1?.cpu).toEqual([metric(7)]);
|
||||
});
|
||||
|
||||
it('reconstructs the response with reset guestTypes/stats and the cached timestamp', () => {
|
||||
const now = 50_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: 12345,
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
const read = readWorkloadsSummaryCache('1h', '', 'acme');
|
||||
expect(read).toEqual({
|
||||
data: {},
|
||||
dockerData: {},
|
||||
guestTypes: {},
|
||||
timestamp: 12345,
|
||||
stats: { oldestDataTimestamp: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('warms the in-memory cache as a side effect of a successful read', () => {
|
||||
const now = 99_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
writePayload('1h', '', 'acme', {
|
||||
version: VERSION,
|
||||
range: '1h',
|
||||
nodeScope: '',
|
||||
cachedAt: now,
|
||||
data: {},
|
||||
dockerData: {},
|
||||
});
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
const read = readWorkloadsSummaryCache('1h', '', 'acme');
|
||||
expect(read).not.toBeNull();
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(read);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasFreshWorkloadsSummaryCache', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns false when neither memory nor storage has an entry (both || arms false)', () => {
|
||||
expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(false);
|
||||
});
|
||||
|
||||
it('short-circuits true on a memory hit (|| left arm)', () => {
|
||||
const response = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', response);
|
||||
// Drop storage so only memory holds it.
|
||||
localStorage.clear();
|
||||
expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls through to storage and returns true on a storage hit (|| right arm)', () => {
|
||||
const now = 1_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse());
|
||||
// Reset memory (keeps localStorage) so only storage holds it.
|
||||
__resetWorkloadsSummaryCacheForTests();
|
||||
expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWorkloadsSummaryAndCache dedupe / signal arms', () => {
|
||||
it('deduplicates concurrent same-key fetches without a signal (inFlight return)', async () => {
|
||||
const response = makeResponse();
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(response);
|
||||
|
||||
const first = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
const second = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([response, response]);
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears inFlight on settlement so a later call fetches again (finally arm)', async () => {
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(makeResponse());
|
||||
|
||||
await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bypasses dedupe when an AbortSignal is supplied (both !signal guards false)', async () => {
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(makeResponse());
|
||||
|
||||
const controller = new AbortController();
|
||||
const p1 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal });
|
||||
const p2 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal });
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('persists the fetched response before resolving', async () => {
|
||||
const response = makeResponse();
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(response);
|
||||
|
||||
const result = await fetchWorkloadsSummaryAndCache('1h', {
|
||||
nodeId: 'n1',
|
||||
orgScope: 'acme',
|
||||
});
|
||||
expect(result).toBe(response);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('org_switched event handler', () => {
|
||||
it('clears the in-memory cache when org_switched is emitted', () => {
|
||||
const response = makeResponse();
|
||||
persistWorkloadsSummaryCache('1h', 'n1', 'acme', response);
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response);
|
||||
|
||||
eventBus.emit('org_switched', 'new-org');
|
||||
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears in-flight fetch tracking so the next request is not deduped', async () => {
|
||||
mockGetWorkloadCharts.mockReset();
|
||||
mockGetWorkloadCharts.mockResolvedValue(makeResponse());
|
||||
|
||||
// Kick off a never-resolving first fetch so inFlight is populated.
|
||||
let resolveFirst!: (v: WorkloadChartsResponse) => void;
|
||||
mockGetWorkloadCharts.mockReturnValueOnce(
|
||||
new Promise<WorkloadChartsResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
void fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
|
||||
// Emit org_switched -> inFlight map is cleared.
|
||||
eventBus.emit('org_switched', 'new-org');
|
||||
|
||||
// A new concurrent call (same key) must NOT be deduped -> 2nd API call.
|
||||
const p2 = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 });
|
||||
expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Allow both promises to settle.
|
||||
mockGetWorkloadCharts.mockResolvedValueOnce(makeResponse());
|
||||
resolveFirst(makeResponse());
|
||||
await p2;
|
||||
});
|
||||
});
|
||||
|
||||
describe('__resetWorkloadsSummaryCacheForTests', () => {
|
||||
it('clears the in-memory cache', () => {
|
||||
persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse());
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull();
|
||||
__resetWorkloadsSummaryCacheForTests();
|
||||
expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user