mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
test(frontend): branch coverage for 6 pure modules (GLM wave 0712 tier 2a)
This commit is contained in:
+272
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getNextAssistantRecentModelRoute,
|
||||
normalizeAssistantModelRouteArgument,
|
||||
normalizeAssistantRecentModelRoutes,
|
||||
} from '../assistantModelRoutes';
|
||||
|
||||
// Branch-coverage companion to assistantModelRoutes.test.ts. Each test below
|
||||
// drives a specific arm (early return, ternary branch, `??` default, optional
|
||||
// chain, guard, modular wrap) of the three named functions and asserts against
|
||||
// concrete outputs rather than truthiness.
|
||||
|
||||
const RECENTS: string[] = [
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
'openrouter:deepseek/deepseek-v4-pro',
|
||||
'gemini:gemini-3.1-flash-lite',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeAssistantModelRouteArgument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeAssistantModelRouteArgument branch coverage', () => {
|
||||
it('passes an explicit route through after trimming surrounding whitespace', () => {
|
||||
expect(
|
||||
normalizeAssistantModelRouteArgument(' openrouter:qwen/qwen3.7-plus ', ['openrouter']),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
|
||||
it('returns null for an empty or whitespace-only argument (falsy candidate)', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('', ['openrouter'])).toBeNull();
|
||||
expect(normalizeAssistantModelRouteArgument(' ', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the candidate contains internal whitespace', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('open router/model', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for url-like arguments containing ://', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('http://example.com/m', ['http'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no slash separator is present', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('plainname', ['plainname'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the slash is the first character', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('/model', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the slash is the last character', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('openrouter/', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the provider segment fails the provider regex', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('123/model', ['123'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the model segment begins with a slash', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('openrouter//qwen', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches known providers case-insensitively while preserving candidate provider case', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('OpenRouter/model', ['openrouter'])).toBe(
|
||||
'OpenRouter:model',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims, lowercases, and drops blank entries in knownProviders before matching', () => {
|
||||
expect(
|
||||
normalizeAssistantModelRouteArgument('openrouter/model', [' OpenRouter ', '', ' ']),
|
||||
).toBe('openrouter:model');
|
||||
});
|
||||
|
||||
it('returns null when knownProviders is empty (default argument)', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('openrouter/model')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the provider is absent from knownProviders', () => {
|
||||
expect(normalizeAssistantModelRouteArgument('foo/model', ['openrouter'])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds the canonical route from a slash-style argument', () => {
|
||||
expect(
|
||||
normalizeAssistantModelRouteArgument('openrouter/qwen/qwen3.7-plus', ['openrouter']),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeAssistantRecentModelRoutes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeAssistantRecentModelRoutes branch coverage', () => {
|
||||
it('skips non-string entries via the typeof ternary', () => {
|
||||
expect(
|
||||
normalizeAssistantRecentModelRoutes(
|
||||
[
|
||||
123,
|
||||
{ model: 'openrouter:qwen/qwen3.7-plus' },
|
||||
null,
|
||||
undefined,
|
||||
true,
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
],
|
||||
10,
|
||||
),
|
||||
).toEqual(['openrouter:qwen/qwen3.7-plus']);
|
||||
});
|
||||
|
||||
it('skips blank and non-explicit strings and trims valid ones', () => {
|
||||
expect(
|
||||
normalizeAssistantRecentModelRoutes(
|
||||
['', ' ', 'plain-model-name', ' openrouter:qwen/qwen3.7-plus '],
|
||||
10,
|
||||
),
|
||||
).toEqual(['openrouter:qwen/qwen3.7-plus']);
|
||||
});
|
||||
|
||||
it('deduplicates explicit routes by their trimmed form', () => {
|
||||
expect(
|
||||
normalizeAssistantRecentModelRoutes(
|
||||
[
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
'deepseek:deepseek-chat',
|
||||
],
|
||||
10,
|
||||
),
|
||||
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
|
||||
});
|
||||
|
||||
it('breaks as soon as the limit is reached, ignoring later valid routes', () => {
|
||||
expect(
|
||||
normalizeAssistantRecentModelRoutes(
|
||||
[
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
'deepseek:deepseek-chat',
|
||||
'gemini:gemini-3.1-flash-lite',
|
||||
],
|
||||
2,
|
||||
),
|
||||
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
|
||||
});
|
||||
|
||||
it('returns every deduped route when the limit exceeds the input length', () => {
|
||||
expect(
|
||||
normalizeAssistantRecentModelRoutes(
|
||||
['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat'],
|
||||
99,
|
||||
),
|
||||
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty input', () => {
|
||||
expect(normalizeAssistantRecentModelRoutes([], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still yields the first entry when limit is 0 (cap evaluated after push)', () => {
|
||||
expect(normalizeAssistantRecentModelRoutes(['openrouter:qwen/qwen3.7-plus'], 0)).toEqual([
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getNextAssistantRecentModelRoute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getNextAssistantRecentModelRoute branch coverage', () => {
|
||||
it('returns null when recentModelIds is empty', () => {
|
||||
expect(getNextAssistantRecentModelRoute({ recentModelIds: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when every recent id is filtered out as non-explicit', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({ recentModelIds: ['plain-model-name', ''] }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults direction to 1 and advances to the next route', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openrouter:qwen/qwen3.7-plus',
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('openrouter:deepseek/deepseek-v4-pro');
|
||||
});
|
||||
|
||||
it('moves backward with direction -1 from the middle of the list', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
direction: -1,
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
|
||||
it('wraps to the last route with direction -1 from the first index', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openrouter:qwen/qwen3.7-plus',
|
||||
direction: -1,
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('gemini:gemini-3.1-flash-lite');
|
||||
});
|
||||
|
||||
it('wraps to the first route with direction 1 from the last index', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'gemini:gemini-3.1-flash-lite',
|
||||
direction: 1,
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
|
||||
it('treats an undefined currentModel as empty and returns the first route', () => {
|
||||
expect(getNextAssistantRecentModelRoute({ recentModelIds: RECENTS })).toBe(
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only currentModel as empty (currentIndex < 0, direction 1)', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: ' ',
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
|
||||
it('matches the current model after trimming surrounding whitespace', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: ' openrouter:qwen/qwen3.7-plus ',
|
||||
direction: 1,
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('openrouter:deepseek/deepseek-v4-pro');
|
||||
});
|
||||
|
||||
it('returns the last route when current is absent and direction is -1', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openai:gpt-4o',
|
||||
direction: -1,
|
||||
recentModelIds: RECENTS,
|
||||
}),
|
||||
).toBe('gemini:gemini-3.1-flash-lite');
|
||||
});
|
||||
|
||||
it('returns null when the only recent route matches the current model', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openrouter:qwen/qwen3.7-plus',
|
||||
recentModelIds: ['openrouter:qwen/qwen3.7-plus'],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the sole recent route when current is absent and it is the only entry', () => {
|
||||
expect(
|
||||
getNextAssistantRecentModelRoute({
|
||||
currentModel: 'openai:gpt-4o',
|
||||
recentModelIds: ['openrouter:qwen/qwen3.7-plus'],
|
||||
}),
|
||||
).toBe('openrouter:qwen/qwen3.7-plus');
|
||||
});
|
||||
});
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseToolCommandPreview, parseToolInputSummary } from '../toolPresentation';
|
||||
|
||||
// Branch-coverage companion to toolPresentation.test.ts and
|
||||
// toolPresentation.coverage.test.ts. The seven target functions
|
||||
// (parseFunctionStyleToolInput, extractPartialJSONStringField,
|
||||
// commandPreviewValue, formatShellCommandPreview, shellCommandIntentLabel,
|
||||
// parseStructuredInputRecord, partialCommandPreview) are all module-private,
|
||||
// so every branch is driven through the two exported entry points
|
||||
// `parseToolInputSummary` and `parseToolCommandPreview`, matching the sibling
|
||||
// suites' convention.
|
||||
|
||||
const readSummary = (record: Record<string, unknown>): string =>
|
||||
parseToolInputSummary(JSON.stringify(record), 'pulse_read');
|
||||
const commandPreview = (input: string, tool: string, rawInput?: string): string =>
|
||||
parseToolCommandPreview(input, tool, rawInput);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseFunctionStyleToolInput — partial-mode branches the sibling suites never
|
||||
// reach: the unquoted-value `)` stop (line 279 ternary, allowPartial arm), the
|
||||
// post-value `)` break (line 298), end-of-body right after `=` (line 267),
|
||||
// empty unquoted rawValue rescue (line 284), and the negative/decimal arms of
|
||||
// the numeric literal matcher (line 288).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseFunctionStyleToolInput — partial-mode branches', () => {
|
||||
it('stops an unquoted value at a mid-body ")" and breaks (lines 279 + 298)', () => {
|
||||
// Strict regex rejects `read(command=df -h)z` (no trailing `)`), so the
|
||||
// partial pass runs: the unquoted scan hits `)` (line 279 allowPartial
|
||||
// arm), then the post-value `)` break fires (line 298). The captured
|
||||
// command flows through to the read-exec intent label.
|
||||
expect(parseToolInputSummary('read(command=df -h)z', 'pulse_read')).toBe(
|
||||
'Inspect filesystems',
|
||||
);
|
||||
});
|
||||
|
||||
it('breaks on ")" after a quoted value when strict mode rejected the call (line 298)', () => {
|
||||
// `read(action="file")extra)` — strict consumes the inner body then fails
|
||||
// at the leftover "extra"; the partial pass slices the trailing ")" and
|
||||
// breaks at the next ")" after the quoted value.
|
||||
expect(parseToolInputSummary('read(action="file")extra)', 'pulse_read')).toBe(
|
||||
'read file',
|
||||
);
|
||||
});
|
||||
|
||||
it('rescues earlier args when the body ends right after "=" (line 267)', () => {
|
||||
// No closing ")" → strict regex fails; partial parses `action="file"`,
|
||||
// reaches `path=`, then end-of-body and returns the partial result.
|
||||
expect(parseToolInputSummary('read(action="file", path=', 'pulse_read')).toBe(
|
||||
'read file',
|
||||
);
|
||||
});
|
||||
|
||||
it('rescues earlier args when an unquoted value is empty (line 284)', () => {
|
||||
// `x=,` yields an empty rawValue; partialResult returns the prior `action`.
|
||||
expect(parseToolInputSummary('read(action="file", x=,)', 'pulse_read')).toBe(
|
||||
'read file',
|
||||
);
|
||||
});
|
||||
|
||||
it('types negative-integer and decimal unquoted values via the numeric matcher arms', () => {
|
||||
expect(parseToolInputSummary('query(action=get, resource_id=-5)', 'pulse_query')).toBe(
|
||||
'get -5',
|
||||
);
|
||||
expect(parseToolInputSummary('query(action=get, resource_id=1.5)', 'pulse_query')).toBe(
|
||||
'get 1.5',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractPartialJSONStringField — the sibling suites only exercise happy-path
|
||||
// command/cmd/path extraction. These cases drive the in-value unescape arms
|
||||
// (`\"` → `"`, `\\` → `\`) and the trailing `.trim()` of a space-padded value.
|
||||
// Reached via partialCommandPreview → parseToolCommandPreview using incomplete
|
||||
// JSON so the full-JSON path is bypassed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractPartialJSONStringField — escape and trim arms', () => {
|
||||
it('unescapes an embedded escaped quote in a partial JSON command value', () => {
|
||||
// Raw chars: {"command":"a\"b — the capture group holds a\"b which the
|
||||
// `\"` → `"` replacement turns into a"b.
|
||||
expect(commandPreview('{"command":"a\\"b', 'pulse_run_command')).toBe('$ a"b');
|
||||
});
|
||||
|
||||
it('unescapes a JSON-encoded backslash in a partial command value', () => {
|
||||
// Raw chars: {"command":"a\\b — capture holds a\\b (two backslashes) which
|
||||
// the `\\` → `\` replacement collapses to a single backslash.
|
||||
expect(commandPreview('{"command":"a\\\\b', 'pulse_run_command')).toBe('$ a\\b');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace inside a partial JSON string value', () => {
|
||||
expect(commandPreview('{"command":" df ', 'pulse_run_command')).toBe('$ df');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// commandPreviewValue / formatShellCommandPreview — the sibling suite only
|
||||
// redacts an `Authorization: Bearer` header. These drive the remaining redaction
|
||||
// arms in redactShellCommandPreview (sk- token, --flag=, Bearer w/o header,
|
||||
// ENV=, lowercase key=) which all flow through commandPreviewValue's
|
||||
// non-truncating branch and formatShellCommandPreview's `$ ` prefix arm.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('commandPreviewValue / formatShellCommandPreview — redaction arms', () => {
|
||||
it('redacts a bare sk- style API key', () => {
|
||||
expect(
|
||||
commandPreview(JSON.stringify({ command: 'echo sk-abcdefghijkl' }), 'pulse_run_command'),
|
||||
).toBe('$ echo [redacted-secret]');
|
||||
});
|
||||
|
||||
it('redacts an --api-key=<value> flag', () => {
|
||||
expect(
|
||||
commandPreview(
|
||||
JSON.stringify({ command: 'curl --api-key=secret12345 https://x' }),
|
||||
'pulse_run_command',
|
||||
),
|
||||
).toBe('$ curl --api-key=[redacted-secret] https://x');
|
||||
});
|
||||
|
||||
it('redacts a Bearer token passed without an Authorization: prefix', () => {
|
||||
expect(
|
||||
commandPreview(
|
||||
JSON.stringify({ command: 'curl -H Bearer abc12345678 https://x' }),
|
||||
'pulse_run_command',
|
||||
),
|
||||
).toBe('$ curl -H Bearer [redacted-secret] https://x');
|
||||
});
|
||||
|
||||
it('redacts an uppercase ENV-var assignment', () => {
|
||||
expect(
|
||||
commandPreview(
|
||||
JSON.stringify({ command: 'API_KEY=secret1234 curl https://x' }),
|
||||
'pulse_run_command',
|
||||
),
|
||||
).toBe('$ API_KEY=[redacted-secret] curl https://x');
|
||||
});
|
||||
|
||||
it('redacts a lowercase password=<value> assignment', () => {
|
||||
expect(
|
||||
commandPreview(
|
||||
JSON.stringify({ command: 'curl -d password=hunter2222 https://x' }),
|
||||
'pulse_run_command',
|
||||
),
|
||||
).toBe('$ curl -d password=[redacted-secret] https://x');
|
||||
});
|
||||
|
||||
it('returns empty when formatShellCommandPreview receives an empty preview', () => {
|
||||
// A whitespace-only command normalizes to "" inside commandPreviewValue,
|
||||
// which formatShellCommandPreview maps to "" (falsy ternary arm).
|
||||
expect(commandPreview(JSON.stringify({ command: ' ' }), 'pulse_run_command')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shellCommandIntentLabel — the sibling suite covers one command per family.
|
||||
// These exercise the remaining regex alternatives inside each intent bucket so
|
||||
// every alternation arm is hit at least once. All flow through read exec.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shellCommandIntentLabel — remaining regex alternations', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
// Inspect devices: udevadm / lspci / lsusb / blkid / nvme list / /dev
|
||||
['udevadm info /dev/sda', 'Inspect devices'],
|
||||
['lspci -v', 'Inspect devices'],
|
||||
['lsusb', 'Inspect devices'],
|
||||
['blkid', 'Inspect devices'],
|
||||
['nvme list', 'Inspect devices'],
|
||||
['cat /dev/sda', 'Inspect devices'],
|
||||
// Check disk health: nvme smart-log / storcli / megacli.
|
||||
// (Note: a literal /dev path here would be shadowed by the device regex's
|
||||
// /dev alternative — see SUSPECTED SOURCE BUGS in GLM_REPORT.md — so these
|
||||
// omit /dev to actually reach the disk-health alternation.)
|
||||
['nvme smart-log nvme0', 'Check disk health'],
|
||||
['storcli /c0 show', 'Check disk health'],
|
||||
['megacli -AdpAllInfo -a0', 'Check disk health'],
|
||||
// Inspect filesystems: findmnt / mount / ls /mnt|media
|
||||
['findmnt /', 'Inspect filesystems'],
|
||||
['mount /mnt/data', 'Inspect filesystems'],
|
||||
['ls /mnt/data', 'Inspect filesystems'],
|
||||
['ls /media/cdrom', 'Inspect filesystems'],
|
||||
// Inspect ZFS storage: bare `zfs`
|
||||
['zfs list', 'Inspect ZFS storage'],
|
||||
// Read logs: docker logs / kubectl logs / tail -f /var/log / /var/log
|
||||
['docker logs nginx', 'Read logs'],
|
||||
['kubectl logs api', 'Read logs'],
|
||||
['tail -f /var/log/syslog', 'Read logs'],
|
||||
['grep ERROR /var/log/syslog', 'Read logs'],
|
||||
// Check service status: is-active / show / service ... status
|
||||
['systemctl is-active nginx', 'Check service status'],
|
||||
['systemctl show nginx', 'Check service status'],
|
||||
['service nginx status', 'Check service status'],
|
||||
// Inspect containers: docker inspect|stats / podman
|
||||
['docker inspect web', 'Inspect containers'],
|
||||
['docker stats', 'Inspect containers'],
|
||||
['podman ps', 'Inspect containers'],
|
||||
['podman stats', 'Inspect containers'],
|
||||
// Inspect Kubernetes: describe|top / helm
|
||||
['kubectl describe pod', 'Inspect Kubernetes resources'],
|
||||
['kubectl top nodes', 'Inspect Kubernetes resources'],
|
||||
['helm list', 'Inspect Kubernetes resources'],
|
||||
['helm status release', 'Inspect Kubernetes resources'],
|
||||
// Inspect Proxmox: pct / qm variants / pvesh / pvesm
|
||||
['pct list', 'Inspect Proxmox resources'],
|
||||
['qm status 101', 'Inspect Proxmox resources'],
|
||||
['qm config 101', 'Inspect Proxmox resources'],
|
||||
['qm pending 101', 'Inspect Proxmox resources'],
|
||||
['pvesh get nodes', 'Inspect Proxmox resources'],
|
||||
['pvesm status', 'Inspect Proxmox resources'],
|
||||
// Check network state: ss / netstat / traceroute / curl / wget / dig / nslookup
|
||||
['ss -tlnp', 'Check network state'],
|
||||
['netstat -an', 'Check network state'],
|
||||
['traceroute 8.8.8.8', 'Check network state'],
|
||||
['curl http://example.com', 'Check network state'],
|
||||
['wget http://example.com', 'Check network state'],
|
||||
['dig example.com', 'Check network state'],
|
||||
['nslookup example.com', 'Check network state'],
|
||||
];
|
||||
|
||||
it.each(cases)('labels %s as %j', (command, expected) => {
|
||||
expect(readSummary({ action: 'exec', command })).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns no intent for an unrecognized command, yielding the generic read-only label', () => {
|
||||
// shellCommandIntentLabel returns "" → formatCommandActivitySummary falls
|
||||
// through to "Run read-only command".
|
||||
expect(readSummary({ action: 'exec', command: 'uname -a' })).toBe('Run read-only command');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseStructuredInputRecord — JSON.parse succeeds but yields a non-record
|
||||
// (null / string), so the `parsed && typeof parsed === 'object'` guard is
|
||||
// false and parsing falls through to the (also failing) function-call path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseStructuredInputRecord — non-record JSON fall-through', () => {
|
||||
it('treats a JSON null literal as a non-record and falls back to the label', () => {
|
||||
// JSON.parse('null') === null → guard false → no function-call match → null.
|
||||
expect(parseToolInputSummary('null', 'pulse_read')).toBe('null');
|
||||
});
|
||||
|
||||
it('treats a JSON string scalar as a non-record and falls back to the label', () => {
|
||||
// JSON.parse('"hello"') === 'hello' → typeof string → guard false → null.
|
||||
expect(parseToolInputSummary('"hello"', 'pulse_read')).toBe('"hello"');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// partialCommandPreview — the sibling suite only covers run_command for the
|
||||
// command-tool guard. These exercise the read and control arms of that guard
|
||||
// plus a backslash-escaped partial value flowing through the helper.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('partialCommandPreview — read/control guard arms', () => {
|
||||
it('extracts a partial command for the read tool', () => {
|
||||
expect(commandPreview('{"command":"df', 'pulse_read')).toBe('$ df');
|
||||
});
|
||||
|
||||
it('extracts a partial command for the control tool', () => {
|
||||
expect(commandPreview('{"command":"df', 'pulse_control')).toBe('$ df');
|
||||
});
|
||||
|
||||
it('still returns empty for a non-command tool even when a command is present', () => {
|
||||
expect(commandPreview('{"command":"df"', 'pulse_query')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty when the partial input has no command or cmd field', () => {
|
||||
expect(commandPreview('{"foo":"bar"', 'pulse_run_command')).toBe('');
|
||||
});
|
||||
|
||||
it('uses the raw input when the trimmed input is empty', () => {
|
||||
expect(commandPreview(' ', 'pulse_run_command', '{"command":"uptime"}')).toBe('$ uptime');
|
||||
});
|
||||
});
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_LOCALE, loadLocaleCatalog, setActiveLocale } from '@/i18n';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
buildSetupCompletionConnectedSystems,
|
||||
type ConnectedSetupSystem,
|
||||
} from '../setupCompletionModel';
|
||||
|
||||
type ResourceSeed = Partial<Resource> & Pick<Resource, 'id' | 'type'>;
|
||||
|
||||
const makeResource = (seed: ResourceSeed): Resource => ({
|
||||
name: '',
|
||||
displayName: '',
|
||||
platformId: '',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: 0,
|
||||
...seed,
|
||||
});
|
||||
|
||||
const namesOf = (systems: readonly ConnectedSetupSystem[]): string[] => systems.map((s) => s.name);
|
||||
|
||||
describe('setupCompletionModel branch coverage (supplemental)', () => {
|
||||
beforeEach(() => {
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
describe('toConnectedSetupSystem', () => {
|
||||
it('falls back to the localized "Unknown" name when no display/host/name identity exists (en)', () => {
|
||||
// Drives the 3rd operand of the name chain (`getUnknownSetupSystemName()`) in
|
||||
// toConnectedSetupSystem: displayName, canonical display, hostname and
|
||||
// getPrimaryResourceIdentity all resolve to '' for an all-empty pbs resource.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({ id: '', type: 'pbs', name: '', displayName: '', platformId: '' }),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]).toStrictEqual({
|
||||
id: '',
|
||||
name: 'Unknown',
|
||||
typeLabel: 'Proxmox Backup Server',
|
||||
host: '',
|
||||
connectionPath: 'api',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the localized unknown name when a non-English catalog is active', async () => {
|
||||
// Exercises the i18n call inside getUnknownSetupSystemName(): in the es catalog
|
||||
// the unknown-name token is 'Desconocido', distinct from the English 'Unknown'.
|
||||
await loadLocaleCatalog('es');
|
||||
setActiveLocale('es');
|
||||
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({ id: '', type: 'pmg', name: '', displayName: '', platformId: '' }),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.name).toBe('Desconocido');
|
||||
expect(systems[0]!.typeLabel).toBe('Proxmox Mail Gateway');
|
||||
expect(systems[0]!.connectionPath).toBe('api');
|
||||
});
|
||||
|
||||
it('routes an agent-facet agent resource onto the agent path deriving id from the agent facet', () => {
|
||||
// connectionPath ternary: isApiConnectedSetupResource false, isAgentConnectedSetupResource true.
|
||||
// id chain: platformId empty -> getActionableAgentIdFromResource -> agent facet id.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'host-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
name: 'box',
|
||||
platformId: '',
|
||||
agent: { agentId: 'agent-99' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.connectionPath).toBe('agent');
|
||||
expect(systems[0]!.id).toBe('agent-99');
|
||||
});
|
||||
|
||||
it('drops an agent resource whose connectionPath resolves to null (no agent facet, no api platform)', () => {
|
||||
// connectionPath ternary third arm: neither api nor agent -> null -> early return null.
|
||||
const orphan = makeResource({
|
||||
id: 'lonely-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
name: 'lone',
|
||||
});
|
||||
expect(buildSetupCompletionConnectedSystems([orphan])).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a non-infrastructure resource type via the isSetupCompletionInfrastructureResource guard', () => {
|
||||
// Early `if (!isSetupCompletionInfrastructureResource(resource)) return null` for type 'storage'.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({ id: 'store-1', type: 'storage', name: 'shelf' }),
|
||||
makeResource({
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
name: 'Tower',
|
||||
agent: { agentId: 'agent-1' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(namesOf(systems)).toEqual(['Tower']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSetupCompletionPlatformLabel (observed via ConnectedSetupSystem.typeLabel)', () => {
|
||||
it('returns null (observed as the "Agent" typeLabel) when platformType is absent from the manifest', () => {
|
||||
// `if (!manifestPlatform) return null` arm; getConnectedSetupSystemTypeLabel falls back to 'Agent'.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'gen-1',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
name: 'box',
|
||||
agent: { agentId: 'gen-1' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems[0]!.typeLabel).toBe('Agent');
|
||||
});
|
||||
|
||||
it('returns null (observed as the "Agent" typeLabel) when platformType is the empty string', () => {
|
||||
// getSetupCompletionPlatformKey returns resource.platformType || null -> '' -> null key -> no manifest.
|
||||
const emptyPlatformType: Resource = {
|
||||
...makeResource({
|
||||
id: 'emptypt-1',
|
||||
type: 'agent',
|
||||
name: 'box',
|
||||
agent: { agentId: 'emptypt-1' },
|
||||
}),
|
||||
platformType: '' as Resource['platformType'],
|
||||
};
|
||||
|
||||
const systems = buildSetupCompletionConnectedSystems([emptyPlatformType]);
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.typeLabel).toBe('Agent');
|
||||
});
|
||||
|
||||
it('returns the last display token for a multi-token platform (vmware-vsphere)', () => {
|
||||
// `displayTokens[displayTokens.length - 1]` truthy arm: ['vSphere', 'VMware vSphere'] -> last.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'vc-1',
|
||||
type: 'agent',
|
||||
platformType: 'vmware-vsphere',
|
||||
name: 'vcsa',
|
||||
displayName: 'VC',
|
||||
platformId: 'vc-1',
|
||||
sourceType: 'api',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems[0]!.typeLabel).toBe('VMware vSphere');
|
||||
});
|
||||
|
||||
it('returns the sole display token for a single-token platform (truenas)', () => {
|
||||
// Single-element displayTokens: ['TrueNAS'] -> displayTokens[0] is the last and only token.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'tn-1',
|
||||
type: 'agent',
|
||||
platformType: 'truenas',
|
||||
name: 'tn',
|
||||
displayName: 'TN',
|
||||
platformId: 'tn-1',
|
||||
sourceType: 'api',
|
||||
identity: { hostname: 'tn-h' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems[0]!.typeLabel).toBe('TrueNAS');
|
||||
});
|
||||
|
||||
it('maps the pbs resource type to the proxmox-pbs manifest key regardless of platformType', () => {
|
||||
// getSetupCompletionPlatformKey hardcodes 'proxmox-pbs' for type pbs (ignoring platformType).
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'pbs-1',
|
||||
type: 'pbs',
|
||||
name: 'pbs',
|
||||
displayName: 'PBS',
|
||||
platformId: 'pbs-1',
|
||||
platformType: 'agent',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems[0]!.typeLabel).toBe('Proxmox Backup Server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSetupCompletionConnectedSystems', () => {
|
||||
it('keys entries by the resolved system name when platformId and system id are both empty', () => {
|
||||
// Exercises the 3rd operand of `key = resource.platformId || nextSystem.id || nextSystem.name`:
|
||||
// two all-empty-identity pbs resources with different resolved names must NOT collide.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({ id: '', type: 'pbs', name: '', displayName: '', platformId: '' }),
|
||||
makeResource({ id: '', type: 'pbs', name: 'realbox', displayName: 'Real Box', platformId: '' }),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(2);
|
||||
expect(namesOf(systems)).toEqual(['Real Box', 'Unknown']);
|
||||
});
|
||||
|
||||
it('does not downgrade an api entry when a later agent entry shares its key', () => {
|
||||
// Merge guard false arms: existing.connectionPath === 'agent' is false (it is 'api'),
|
||||
// !existing.host is false (host present), isUnknownSetupSystemName(existing.name) is false.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'vc-a',
|
||||
type: 'agent',
|
||||
platformType: 'vmware-vsphere',
|
||||
name: 'vcsa',
|
||||
displayName: 'VC Box',
|
||||
platformId: 'shared',
|
||||
sourceType: 'api',
|
||||
identity: { hostname: 'vc-host' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'ag-b',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
name: 'Tower',
|
||||
displayName: 'Tower',
|
||||
platformId: 'shared',
|
||||
agent: { agentId: 'ag-b' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]).toStrictEqual({
|
||||
id: 'shared',
|
||||
name: 'VC Box',
|
||||
typeLabel: 'VMware vSphere',
|
||||
host: 'vc-host',
|
||||
connectionPath: 'api',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not replace a known existing name with a later "Unknown" same-key entry', () => {
|
||||
// Name-replace guard false arm: isUnknownSetupSystemName(existing.name) is false because
|
||||
// 'Real One' is not the unknown token, so existing.name is retained.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'pbs-a',
|
||||
type: 'pbs',
|
||||
name: 'real1',
|
||||
displayName: 'Real One',
|
||||
platformId: 'shared3',
|
||||
}),
|
||||
makeResource({
|
||||
id: 'pbs-b',
|
||||
type: 'pbs',
|
||||
name: 'unk',
|
||||
displayName: 'Unknown',
|
||||
platformId: 'shared3',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.name).toBe('Real One');
|
||||
expect(systems[0]!.connectionPath).toBe('api');
|
||||
});
|
||||
|
||||
it('treats the localized "Desconocido" name as unknown under the es catalog and replaces it', async () => {
|
||||
// isUnknownSetupSystemName second || arm (`name === getUnknownSetupSystemName()`): under es
|
||||
// the unknown token is 'Desconocido', so an existing 'Desconocido' entry is replaced.
|
||||
await loadLocaleCatalog('es');
|
||||
setActiveLocale('es');
|
||||
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'pbs-d',
|
||||
type: 'pbs',
|
||||
name: 'd1',
|
||||
displayName: 'Desconocido',
|
||||
platformId: 'shared4',
|
||||
}),
|
||||
makeResource({
|
||||
id: 'pbs-e',
|
||||
type: 'pbs',
|
||||
name: 'r1',
|
||||
displayName: 'Real Deal',
|
||||
platformId: 'shared4',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.name).toBe('Real Deal');
|
||||
});
|
||||
|
||||
it('does not treat "Desconocido" as unknown under the en catalog (first || arm only)', () => {
|
||||
// Counterpart to the es case: under en, getUnknownSetupSystemName() === 'Unknown', so
|
||||
// 'Desconocido' is NOT unknown and the existing name is retained.
|
||||
const systems = buildSetupCompletionConnectedSystems([
|
||||
makeResource({
|
||||
id: 'pbs-d',
|
||||
type: 'pbs',
|
||||
name: 'd1',
|
||||
displayName: 'Desconocido',
|
||||
platformId: 'shared4',
|
||||
}),
|
||||
makeResource({
|
||||
id: 'pbs-e',
|
||||
type: 'pbs',
|
||||
name: 'r1',
|
||||
displayName: 'Real Deal',
|
||||
platformId: 'shared4',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(systems).toHaveLength(1);
|
||||
expect(systems[0]!.name).toBe('Desconocido');
|
||||
});
|
||||
});
|
||||
});
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
createWorkloadSortComparator,
|
||||
filterWorkloads,
|
||||
} from '@/components/Workloads/workloadSelectors';
|
||||
|
||||
const makeGuest = (i: number, overrides?: Partial<WorkloadGuest>): WorkloadGuest => ({
|
||||
id: `guest-${i}`,
|
||||
vmid: 100 + i,
|
||||
name: `workload-${i}`,
|
||||
node: `node-${i % 5}`,
|
||||
instance: `cluster-${i % 3}`,
|
||||
status: 'running',
|
||||
type: 'vm',
|
||||
cpu: (i % 100) / 100,
|
||||
cpus: 2,
|
||||
memory: { total: 4096, used: ((i % 80) / 100) * 4096, free: 0, usage: (i % 80) / 100 },
|
||||
disk: { total: 102400, used: ((i % 60) / 100) * 102400, free: 0, usage: (i % 60) / 100 },
|
||||
networkIn: i * 100,
|
||||
networkOut: i * 50,
|
||||
diskRead: i * 10,
|
||||
diskWrite: i * 5,
|
||||
uptime: i * 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: new Date().toISOString(),
|
||||
workloadType: 'vm',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseFilterParams = {
|
||||
viewMode: 'all' as const,
|
||||
statusMode: 'all',
|
||||
searchTerm: '',
|
||||
selectedNode: null,
|
||||
selectedHostHint: null,
|
||||
selectedKubernetesContext: null,
|
||||
};
|
||||
|
||||
describe('workloadSelectors (branch coverage 2)', () => {
|
||||
describe('createWorkloadSortComparator', () => {
|
||||
it('returns null for an empty sortKey (guard arm)', () => {
|
||||
expect(createWorkloadSortComparator('', 'asc')).toBeNull();
|
||||
expect(createWorkloadSortComparator('', 'desc')).toBeNull();
|
||||
});
|
||||
|
||||
it('scales cpu by 100 and toggles asc/desc on the numeric branch', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'high', name: 'high', cpu: 0.8 }),
|
||||
makeGuest(2, { id: 'low', name: 'low', cpu: 0.2 }),
|
||||
];
|
||||
const asc = createWorkloadSortComparator('cpu', 'asc')!;
|
||||
const desc = createWorkloadSortComparator('cpu', 'desc')!;
|
||||
// cpu*100: low=20 < high=80
|
||||
expect([...guests].sort(asc).map((g) => g.id)).toEqual(['low', 'high']);
|
||||
expect([...guests].sort(desc).map((g) => g.id)).toEqual(['high', 'low']);
|
||||
});
|
||||
|
||||
it('treats null memory as 0 via the `a.memory ? ... : 0` ternary false arm', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'null-mem',
|
||||
name: 'null-mem',
|
||||
memory: null as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
makeGuest(2, { id: 'big-mem', name: 'big-mem', memory: { total: 100, used: 80, free: 20, usage: 0.8 } }),
|
||||
];
|
||||
const asc = createWorkloadSortComparator('memory', 'asc')!;
|
||||
// null-mem -> 0 < 0.8
|
||||
expect([...guests].sort(asc).map((g) => g.id)).toEqual(['null-mem', 'big-mem']);
|
||||
});
|
||||
|
||||
it('coerces memory.usage of 0 to 0 via the `|| 0` fallback and tiebreaks equals', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'b', name: 'beta', memory: { total: 100, used: 0, free: 100, usage: 0 } }),
|
||||
makeGuest(2, { id: 'a', name: 'alpha', memory: { total: 100, used: 0, free: 100, usage: 0 } }),
|
||||
makeGuest(3, { id: 'c', name: 'gamma', memory: { total: 100, used: 50, free: 50, usage: 0.5 } }),
|
||||
];
|
||||
const asc = createWorkloadSortComparator('memory', 'asc')!;
|
||||
// a,b both 0 -> equal numeric -> tiebreak (alpha<beta); c=0.5 last
|
||||
expect([...guests].sort(asc).map((g) => g.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('clamps negative diskRead/diskWrite to 0 on the diskIo branch', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'neg', name: 'neg', diskRead: -40, diskWrite: -20 }),
|
||||
makeGuest(2, { id: 'pos', name: 'pos', diskRead: 100, diskWrite: 50 }),
|
||||
];
|
||||
const asc = createWorkloadSortComparator('diskIo', 'asc')!;
|
||||
// neg -> max(0,-40)+max(0,-20)=0; pos -> 150
|
||||
expect([...guests].sort(asc).map((g) => g.id)).toEqual(['neg', 'pos']);
|
||||
});
|
||||
|
||||
it('sums netIo with the max(0,...) clamp in both directions', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'busy', name: 'busy', networkIn: 200, networkOut: 100 }),
|
||||
makeGuest(2, { id: 'quiet', name: 'quiet', networkIn: 5, networkOut: 3 }),
|
||||
];
|
||||
expect([...guests].sort(createWorkloadSortComparator('netIo', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'quiet',
|
||||
'busy',
|
||||
]);
|
||||
expect([...guests].sort(createWorkloadSortComparator('netIo', 'desc')!).map((g) => g.id)).toEqual([
|
||||
'busy',
|
||||
'quiet',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a both-empty disk pair through tiebreak and orders empties last in both directions', () => {
|
||||
const emptyDisk = { total: 0, used: 50, free: 0, usage: NaN } as WorkloadGuest['disk'];
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'zz', name: 'zebra', disk: emptyDisk }),
|
||||
makeGuest(2, { id: 'aa', name: 'alpha', disk: emptyDisk }),
|
||||
makeGuest(3, { id: 'mm', name: 'mid', disk: { total: 100, used: 20, free: 80, usage: 0.2 } }),
|
||||
makeGuest(4, { id: 'bb', name: 'bravo', disk: { total: 100, used: 90, free: 10, usage: 0.9 } }),
|
||||
];
|
||||
// getDiskUsagePercent(emptyDisk) -> total 0 -> null -> isEmpty. Non-empty sort first;
|
||||
// empty pair -> aIsEmpty && bIsEmpty -> tiebreak (alpha<zebra).
|
||||
expect([...guests].sort(createWorkloadSortComparator('disk', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'mm',
|
||||
'bb',
|
||||
'aa',
|
||||
'zz',
|
||||
]);
|
||||
// desc only flips the non-empty comparison; empties still sort last by name asc.
|
||||
expect([...guests].sort(createWorkloadSortComparator('disk', 'desc')!).map((g) => g.id)).toEqual([
|
||||
'bb',
|
||||
'mm',
|
||||
'aa',
|
||||
'zz',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pushes a single empty disk value to the end via the aIsEmpty/bIsEmpty arms', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'empty',
|
||||
name: 'empty',
|
||||
disk: undefined as unknown as WorkloadGuest['disk'],
|
||||
}),
|
||||
makeGuest(2, { id: 'full', name: 'full', disk: { total: 100, used: 50, free: 50, usage: 0.5 } }),
|
||||
];
|
||||
// empty -> getDiskUsagePercent null -> aIsEmpty -> return 1 (sorts last) in both dirs.
|
||||
expect([...guests].sort(createWorkloadSortComparator('disk', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'full',
|
||||
'empty',
|
||||
]);
|
||||
expect([...guests].sort(createWorkloadSortComparator('disk', 'desc')!).map((g) => g.id)).toEqual([
|
||||
'full',
|
||||
'empty',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the generic-key else branch for a string field with asc/desc', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'a', name: 'alpha', status: 'stopped' }),
|
||||
makeGuest(2, { id: 'b', name: 'beta', status: 'running' }),
|
||||
];
|
||||
// 'running' < 'stopped'
|
||||
expect([...guests].sort(createWorkloadSortComparator('status', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'b',
|
||||
'a',
|
||||
]);
|
||||
expect([...guests].sort(createWorkloadSortComparator('status', 'desc')!).map((g) => g.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hits the string-equal tiebreak when a generic string key matches on both sides', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'b-id', name: 'beta', status: 'running' }),
|
||||
makeGuest(2, { id: 'a-id', name: 'alpha', status: 'running' }),
|
||||
];
|
||||
// status 'running' === 'running' -> aStr===bStr -> tiebreak (alpha<beta).
|
||||
expect([...guests].sort(createWorkloadSortComparator('status', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'a-id',
|
||||
'b-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty-string generic value as empty and sorts it last', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'empty', name: 'empty', status: '' }),
|
||||
makeGuest(2, { id: 'full', name: 'full', status: 'running' }),
|
||||
];
|
||||
// aVal '' -> aIsEmpty -> sorts last.
|
||||
expect([...guests].sort(createWorkloadSortComparator('status', 'asc')!).map((g) => g.id)).toEqual([
|
||||
'full',
|
||||
'empty',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 0 only when numeric, name, and id are all equal', () => {
|
||||
const a = makeGuest(1, { id: 'dup', name: 'same', cpu: 0.5 });
|
||||
const b = makeGuest(2, { id: 'dup', name: 'same', cpu: 0.5 });
|
||||
const cmp = createWorkloadSortComparator('cpu', 'asc')!;
|
||||
expect(cmp(a, b)).toBe(0);
|
||||
expect(cmp(b, a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tiebreak (exercised via createWorkloadSortComparator)', () => {
|
||||
it('compares lowercased names and returns -1/1 for nameA</>nameB', () => {
|
||||
const alpha = makeGuest(1, { id: 'x', name: 'alpha', cpu: 0.5 });
|
||||
const beta = makeGuest(2, { id: 'x', name: 'Beta', cpu: 0.5 });
|
||||
const cmp = createWorkloadSortComparator('cpu', 'asc')!;
|
||||
// equal cpu -> tiebreak; lowercased 'alpha' < 'beta' regardless of original case.
|
||||
expect(cmp(alpha, beta)).toBe(-1);
|
||||
expect(cmp(beta, alpha)).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to id comparison (all three arms) when names are equal', () => {
|
||||
const a = makeGuest(1, { id: 'a-id', name: 'same', cpu: 0.5 });
|
||||
const b = makeGuest(2, { id: 'b-id', name: 'same', cpu: 0.5 });
|
||||
const c = makeGuest(3, { id: 'a-id', name: 'same', cpu: 0.5 });
|
||||
const cmp = createWorkloadSortComparator('cpu', 'asc')!;
|
||||
expect(cmp(a, b)).toBe(-1); // a.id < b.id
|
||||
expect(cmp(b, a)).toBe(1); // b.id > a.id
|
||||
expect(cmp(a, c)).toBe(0); // ids equal -> 0
|
||||
});
|
||||
|
||||
it('coerces an undefined name to "" via the `(a.name || "")` guard', () => {
|
||||
const a = makeGuest(1, {
|
||||
id: 'b-id',
|
||||
name: undefined as unknown as WorkloadGuest['name'],
|
||||
cpu: 0.5,
|
||||
});
|
||||
const b = makeGuest(2, {
|
||||
id: 'a-id',
|
||||
name: undefined as unknown as WorkloadGuest['name'],
|
||||
cpu: 0.5,
|
||||
});
|
||||
const cmp = createWorkloadSortComparator('cpu', 'asc')!;
|
||||
// both names -> "" (equal) -> id compare: a-id < b-id.
|
||||
expect(cmp(a, b)).toBe(1);
|
||||
expect(cmp(b, a)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterWorkloads', () => {
|
||||
it('returns the input array identity when no filters are active', () => {
|
||||
const guests = [makeGuest(1), makeGuest(2)];
|
||||
expect(filterWorkloads({ ...baseFilterParams, guests })).toBe(guests);
|
||||
});
|
||||
|
||||
it('skips the node-scope filter in pod view so pods on other nodes survive', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
node: 'worker-a',
|
||||
instance: 'ctx',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'default',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'pod-b',
|
||||
name: 'web',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
node: 'worker-b',
|
||||
instance: 'ctx',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'default',
|
||||
}),
|
||||
];
|
||||
// workloadHostScopeId(pod) === '' would drop both; viewMode==='pod' skips the guard.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, viewMode: 'pod', selectedNode: 'worker-a' }).map(
|
||||
(g) => g.id,
|
||||
),
|
||||
).toEqual(['pod-a', 'pod-b']);
|
||||
});
|
||||
|
||||
it('filters by workloadHostScopeId when nodeScope is set in a non-pod view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'vm-a', type: 'vm', workloadType: 'vm', instance: 'cluster-a', node: 'node-a' }),
|
||||
makeGuest(2, { id: 'vm-b', type: 'vm', workloadType: 'vm', instance: 'cluster-b', node: 'node-b' }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
selectedNode: 'cluster-a-node-a',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['vm-a']);
|
||||
});
|
||||
|
||||
it('gives nodeScope precedence over hostHint (hostHint filter skipped when nodeScope set)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'vm-a', type: 'vm', workloadType: 'vm', instance: 'cluster-a', node: 'node-a' }),
|
||||
makeGuest(2, {
|
||||
id: 'app-a',
|
||||
name: 'redis',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
contextLabel: 'edge-host',
|
||||
node: '',
|
||||
instance: '',
|
||||
}),
|
||||
];
|
||||
// nodeScope set -> hostHint branch (`!nodeScope`) is skipped; only vm-a matches the scope.
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
selectedNode: 'cluster-a-node-a',
|
||||
selectedHostHint: 'edge',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['vm-a']);
|
||||
});
|
||||
|
||||
it('excludes pods and keeps matching app-containers in the hostHint branch', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-x',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'edge-host',
|
||||
namespace: 'default',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'app-x',
|
||||
name: 'redis',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
contextLabel: 'edge-host',
|
||||
node: '',
|
||||
instance: '',
|
||||
}),
|
||||
];
|
||||
// pod -> resolveWorkloadType==='pod' -> false; app-container candidate matches 'edge'.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, selectedHostHint: 'edge' }).map((g) => g.id),
|
||||
).toEqual(['app-x']);
|
||||
});
|
||||
|
||||
it('skips the hostHint filter when viewMode is pod', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'default',
|
||||
}),
|
||||
];
|
||||
// hostHint set but viewMode==='pod' -> `viewMode !== 'pod'` false -> skipped; pod kept.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, viewMode: 'pod', selectedHostHint: 'nope' }).map(
|
||||
(g) => g.id,
|
||||
),
|
||||
).toEqual(['pod-a']);
|
||||
});
|
||||
|
||||
it('filters pods by kubernetes context key in pod view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'prod-context',
|
||||
namespace: 'default',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'pod-b',
|
||||
name: 'web',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'stage-context',
|
||||
namespace: 'default',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'pod',
|
||||
selectedKubernetesContext: 'prod-context',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['pod-a']);
|
||||
});
|
||||
|
||||
it('skips the kubernetes context filter when viewMode is not pod', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'prod-context',
|
||||
namespace: 'default',
|
||||
}),
|
||||
makeGuest(2, { id: 'vm-a', type: 'vm', workloadType: 'vm', instance: 'i', node: 'n' }),
|
||||
];
|
||||
// viewMode 'all' -> k8s context guard skipped -> both remain.
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
selectedKubernetesContext: 'prod-context',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['pod-a', 'vm-a']);
|
||||
});
|
||||
|
||||
it('drops non-pod guests during kubernetes namespace filtering in pod view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'ctx',
|
||||
node: 'worker',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'payments',
|
||||
}),
|
||||
makeGuest(2, { id: 'vm-a', type: 'vm', workloadType: 'vm', namespace: 'payments' }),
|
||||
];
|
||||
// vm -> resolveWorkloadType !== 'pod' -> return false in the namespace filter.
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'pod',
|
||||
selectedKubernetesNamespace: 'payments',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['pod-a']);
|
||||
});
|
||||
|
||||
it('filters vms by cluster name in vm view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'vm-a',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
instance: 'i',
|
||||
node: 'n',
|
||||
clusterName: 'prod-cluster',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'vm-b',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
instance: 'i',
|
||||
node: 'n',
|
||||
clusterName: 'dev-cluster',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'vm',
|
||||
selectedCluster: 'prod-cluster',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['vm-a']);
|
||||
});
|
||||
|
||||
it('skips the cluster filter when viewMode is not vm', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'vm-a',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
instance: 'i',
|
||||
node: 'n',
|
||||
clusterName: 'prod-cluster',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'vm-b',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
instance: 'i',
|
||||
node: 'n',
|
||||
clusterName: 'dev-cluster',
|
||||
}),
|
||||
];
|
||||
// viewMode 'all' -> cluster guard skipped -> both remain.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, selectedCluster: 'prod-cluster' }).map((g) => g.id),
|
||||
).toEqual(['vm-a', 'vm-b']);
|
||||
});
|
||||
|
||||
it('keeps only container-typed workloads under the combined container view mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'app', type: 'app-container', workloadType: 'app-container', contextLabel: 'h' }),
|
||||
makeGuest(2, { id: 'lxc', type: 'lxc', workloadType: 'system-container', instance: 'i', node: 'n' }),
|
||||
makeGuest(3, { id: 'vm', type: 'vm', workloadType: 'vm', instance: 'i', node: 'n' }),
|
||||
makeGuest(4, {
|
||||
id: 'pod',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
instance: 'c',
|
||||
node: 'w',
|
||||
contextLabel: 'c',
|
||||
namespace: 'd',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, viewMode: 'container' }).map((g) => g.id),
|
||||
).toEqual(['app', 'lxc']);
|
||||
});
|
||||
|
||||
it('skips the platform filter when the normalized platform is "all"', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'tn',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['truenas'],
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'dk',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['docker'],
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, selectedPlatform: 'all' }).map((g) => g.id),
|
||||
).toEqual(['tn', 'dk']);
|
||||
});
|
||||
|
||||
it('applies workloadMatchesPlatformScope for a concrete platform', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'tn',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['truenas'],
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'dk',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['docker'],
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, selectedPlatform: 'truenas' }).map((g) => g.id),
|
||||
).toEqual(['tn']);
|
||||
});
|
||||
|
||||
it('drops system-containers in the runtime filter under the combined container view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'app-1',
|
||||
name: 'redis',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
containerRuntime: 'docker',
|
||||
contextLabel: 'h',
|
||||
node: '',
|
||||
instance: '',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'lxc-1',
|
||||
name: 'db',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
instance: 'i',
|
||||
node: 'n',
|
||||
containerRuntime: 'docker',
|
||||
}),
|
||||
];
|
||||
// 'container' view keeps both; runtime filter drops the system-container
|
||||
// (resolveWorkloadType !== 'app-container' -> return false).
|
||||
expect(
|
||||
filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'container',
|
||||
containerRuntime: 'docker',
|
||||
}).map((g) => g.id),
|
||||
).toEqual(['app-1']);
|
||||
});
|
||||
|
||||
it('skips the containerRuntime filter when viewMode is not a container mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'c1',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
containerRuntime: 'docker',
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'c2',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
containerRuntime: 'podman',
|
||||
contextLabel: 'h',
|
||||
}),
|
||||
];
|
||||
// isContainerWorkloadViewMode('all') === false -> filter skipped.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, containerRuntime: 'docker' }).map((g) => g.id),
|
||||
).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('matches status exactly (case-sensitive) in running mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'lower', status: 'running' }),
|
||||
makeGuest(2, { id: 'capital', status: 'Running' }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'running' }).map((g) => g.id),
|
||||
).toEqual(['lower']);
|
||||
});
|
||||
|
||||
it('counts DEGRADED-set and unknown statuses as degraded, excluding running and OFFLINE-set', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'warn', status: 'warning' }), // DEGRADED set
|
||||
makeGuest(2, { id: 'migrating', status: 'migrating' }), // unknown -> second condition
|
||||
makeGuest(3, { id: 'stopped', status: 'stopped' }), // OFFLINE set -> excluded
|
||||
makeGuest(4, { id: 'running', status: 'running' }), // running -> excluded
|
||||
makeGuest(5, { id: 'empty', status: '' }), // '' -> unknown -> second condition
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'degraded' }).map((g) => g.id),
|
||||
).toEqual(['warn', 'migrating', 'empty']);
|
||||
});
|
||||
|
||||
it('treats capitalized "Running" as stopped due to the case-sensitive !== running check', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'capital', status: 'Running' }),
|
||||
makeGuest(2, { id: 'lower', status: 'running' }),
|
||||
makeGuest(3, { id: 'stopped', status: 'stopped' }),
|
||||
];
|
||||
// stopped mode keeps g.status !== 'running' -> 'Running' survives (case quirk).
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'stopped' }).map((g) => g.id),
|
||||
).toEqual(['capital', 'stopped']);
|
||||
});
|
||||
|
||||
it('applies no status filter for an unrecognized statusMode (else arm)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'a', status: 'running' }),
|
||||
makeGuest(2, { id: 'b', status: 'stopped' }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'all' }).map((g) => g.id),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('parses a ">" metric filter part and evaluates it via evaluateFilterStack', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'high', name: 'high', cpu: 0.8 }),
|
||||
makeGuest(2, { id: 'low', name: 'low', cpu: 0.5 }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'cpu>70' }).map((g) => g.id),
|
||||
).toEqual(['high']);
|
||||
});
|
||||
|
||||
it('parses a "<" metric filter part (the includes("<") arm of the filter-char detection)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'high', name: 'high', cpu: 0.8 }),
|
||||
makeGuest(2, { id: 'low', name: 'low', cpu: 0.2 }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'cpu<50' }).map((g) => g.id),
|
||||
).toEqual(['low']);
|
||||
});
|
||||
|
||||
it('parses a ":" text filter part (the includes(":") arm of the filter-char detection)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'prod', name: 'prod-api' }),
|
||||
makeGuest(2, { id: 'dev', name: 'dev-api' }),
|
||||
];
|
||||
// 'name:prod' -> text condition { field: 'name', value: 'prod' }.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'name:prod' }).map((g) => g.id),
|
||||
).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('hides rows matching a "-term" exclusion while keeping comma-separated OR text', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'a', name: 'alpha' }),
|
||||
makeGuest(2, { id: 'b', name: 'beta-worker' }),
|
||||
makeGuest(3, { id: 'c', name: 'gamma' }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: '-worker' }).map((g) => g.id),
|
||||
).toEqual(['a', 'c']);
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'alpha, gamma' }).map((g) => g.id),
|
||||
).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('combines metric filter, exclusion, and OR text search in one pass', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'g1', name: 'alpha-api', cpu: 0.8 }),
|
||||
makeGuest(2, { id: 'g2', name: 'beta-worker', cpu: 0.9 }),
|
||||
makeGuest(3, { id: 'g3', name: 'alpha-big', cpu: 0.95 }),
|
||||
makeGuest(4, { id: 'g4', name: 'alpha-low', cpu: 0.5 }),
|
||||
];
|
||||
// cpu>70 keeps g1,g2,g3; -beta drops g2; text "alpha" keeps g1,g3.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'cpu>70, alpha, -beta' }).map(
|
||||
(g) => g.id,
|
||||
),
|
||||
).toEqual(['g1', 'g3']);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only searchTerm as no search and returns the input identity', () => {
|
||||
const guests = [makeGuest(1), makeGuest(2)];
|
||||
expect(filterWorkloads({ ...baseFilterParams, guests, searchTerm: ' ' })).toBe(guests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesWorkloadTextSearch (exercised via filterWorkloads)', () => {
|
||||
it('matches a numeric vmid candidate through the string|number type guard', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'g1', name: 'host-a', vmid: 7777 }),
|
||||
makeGuest(2, { id: 'g2', name: 'host-b', vmid: 1 }),
|
||||
];
|
||||
// vmid is a number -> passes guard -> String(7777).includes('7777').
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: '7777' }).map((g) => g.id),
|
||||
).toEqual(['g1']);
|
||||
});
|
||||
|
||||
it('matches a string status candidate case-insensitively', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'g1', name: 'a', status: 'quarantined' }),
|
||||
makeGuest(2, { id: 'g2', name: 'b', status: 'running' }),
|
||||
];
|
||||
// needle lowercased; candidate lowercased -> 'QUARANTINED' still matches.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'QUARANTINED' }).map((g) => g.id),
|
||||
).toEqual(['g1']);
|
||||
});
|
||||
|
||||
it('does not match a guest whose only candidate is an empty joined platformScopes string', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'empty',
|
||||
name: 'alpha',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: [],
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'scope',
|
||||
name: 'beta',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['uniquematch'],
|
||||
}),
|
||||
];
|
||||
// [].join(' ') -> '' -> String('').includes('uniquematch') is false.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'uniquematch' }).map((g) => g.id),
|
||||
).toEqual(['scope']);
|
||||
});
|
||||
|
||||
it('matches a multi-element platformScopes candidate joined with a space', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'multi',
|
||||
name: 'gamma',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['foo', 'bar-baz'],
|
||||
}),
|
||||
];
|
||||
// join -> 'foo bar-baz' -> includes('bar-baz').
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'bar-baz' }).map((g) => g.id),
|
||||
).toEqual(['multi']);
|
||||
});
|
||||
|
||||
it('returns no guests when no candidate contains the needle', () => {
|
||||
const guests = [makeGuest(1, { id: 'g1', name: 'alpha', vmid: 1 })];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'zzzz-nope' }).map((g) => g.id),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches against the image, namespace, and contextLabel candidates', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'img',
|
||||
name: 'a',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
image: 'registry.local/api:v2',
|
||||
namespace: 'default',
|
||||
contextLabel: 'ctx',
|
||||
instance: 'ctx',
|
||||
node: 'w',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'ns',
|
||||
name: 'b',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
namespace: 'payments-uniq',
|
||||
contextLabel: 'ctx',
|
||||
instance: 'ctx',
|
||||
node: 'w',
|
||||
}),
|
||||
makeGuest(3, {
|
||||
id: 'ctx',
|
||||
name: 'c',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
contextLabel: 'contextlabel-uniq',
|
||||
namespace: 'd',
|
||||
instance: 'ctx',
|
||||
node: 'w',
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'registry.local' }).map((g) => g.id),
|
||||
).toEqual(['img']);
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'payments-uniq' }).map((g) => g.id),
|
||||
).toEqual(['ns']);
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'contextlabel-uniq' }).map((g) => g.id),
|
||||
).toEqual(['ctx']);
|
||||
});
|
||||
});
|
||||
});
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import type { IntelligenceHealthScore } from '@/types/aiIntelligence';
|
||||
import {
|
||||
getInvestigationMessagesState,
|
||||
getPatrolFindingsEmptyState,
|
||||
} from '@/utils/patrolEmptyStatePresentation';
|
||||
|
||||
// Branch-coverage supplement for patrolEmptyStatePresentation. The three target
|
||||
// functions exercised here are getInvestigationMessagesState (exported),
|
||||
// getPatrolRunSnapshotEmptyState (module-private -> driven through
|
||||
// getPatrolFindingsEmptyState with filter 'all' + empty finding_ids), and
|
||||
// getLatestRunCoverageContext (module-private -> driven through the healthy
|
||||
// 'active' clear-state path).
|
||||
|
||||
const makeRun = (overrides: Partial<PatrolRunRecord> = {}): PatrolRunRecord => ({
|
||||
id: 'run-1',
|
||||
started_at: '2026-07-12T10:00:00Z',
|
||||
completed_at: '2026-07-12T10:01:00Z',
|
||||
duration_ms: 60_000,
|
||||
type: 'patrol',
|
||||
resources_checked: 0,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const HEALTHY_OVERALL: IntelligenceHealthScore = {
|
||||
score: 100,
|
||||
grade: 'A',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'Infrastructure is healthy with no significant issues detected.',
|
||||
};
|
||||
|
||||
describe('getInvestigationMessagesState (branchcov2)', () => {
|
||||
it('prefers the loading state even when messages are already present', () => {
|
||||
// Exercises the first `if (loading)` arm and confirms it short-circuits the
|
||||
// hasMessages check (loading=true, hasMessages=true).
|
||||
expect(getInvestigationMessagesState(true, true)).toStrictEqual({
|
||||
text: 'Loading messages...',
|
||||
empty: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a neutral non-empty state when messages exist and nothing is loading', () => {
|
||||
// Exercises the final fall-through return (loading=false, hasMessages=true),
|
||||
// the only arm the sibling test file does not reach.
|
||||
expect(getInvestigationMessagesState(false, true)).toStrictEqual({
|
||||
text: '',
|
||||
empty: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolRunSnapshotEmptyState (exercised via getPatrolFindingsEmptyState)', () => {
|
||||
it('uses the info tone with a coverage prefix for a healthy run that covered part of the scope', () => {
|
||||
// Healthy arm (status healthy, no errors) + truthy coverageSummary arm of
|
||||
// the `coveragePrefix` ternary.
|
||||
expect(
|
||||
getPatrolFindingsEmptyState({
|
||||
filter: 'all',
|
||||
runSnapshot: {
|
||||
resources_checked: 1,
|
||||
scope_resource_ids: ['seed-resource'],
|
||||
effective_scope_resource_ids: ['expanded-a', 'expanded-b'],
|
||||
finding_ids: [],
|
||||
status: 'healthy',
|
||||
error_count: 0,
|
||||
},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
title: 'No findings recorded for this run',
|
||||
body: 'Checked 1 of 2 scoped resources. This run recorded no Patrol findings.',
|
||||
tone: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the info tone with no coverage prefix for a healthy run that checked nothing', () => {
|
||||
// Healthy arm + falsy coverageSummary arm (empty prefix) of the ternary.
|
||||
expect(
|
||||
getPatrolFindingsEmptyState({
|
||||
filter: 'all',
|
||||
runSnapshot: {
|
||||
resources_checked: 0,
|
||||
scope_resource_ids: [],
|
||||
effective_scope_resource_ids: [],
|
||||
finding_ids: [],
|
||||
status: 'healthy',
|
||||
error_count: 0,
|
||||
},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
title: 'No findings recorded for this run',
|
||||
body: 'This run recorded no Patrol findings.',
|
||||
tone: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the warning tone with no coverage prefix for an unhealthy run that checked nothing', () => {
|
||||
// Unhealthy arm + falsy coverageSummary arm. The sibling test only covers
|
||||
// the unhealthy arm together with a non-empty coverage prefix, so this
|
||||
// newly exercises the empty-prefix combination.
|
||||
expect(
|
||||
getPatrolFindingsEmptyState({
|
||||
filter: 'all',
|
||||
runSnapshot: {
|
||||
resources_checked: 0,
|
||||
scope_resource_ids: [],
|
||||
effective_scope_resource_ids: [],
|
||||
finding_ids: [],
|
||||
status: 'error',
|
||||
error_count: 1,
|
||||
},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
title: 'No findings recorded for this run',
|
||||
body: 'This run recorded no Patrol findings, but it ended with issues requiring review.',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestRunCoverageContext (exercised via getPatrolFindingsEmptyState)', () => {
|
||||
it('returns no body context when the runs array is empty', () => {
|
||||
// Exercises the `runs.length === 0` arm of the first guard (distinct from
|
||||
// the `!runs` arm already covered when runs is omitted entirely).
|
||||
const result = getPatrolFindingsEmptyState({
|
||||
filter: 'active',
|
||||
overallHealth: HEALTHY_OVERALL,
|
||||
runtimeState: 'active',
|
||||
runs: [],
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
title: 'No current issues',
|
||||
body: undefined,
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no body context when the latest run has no coverage summary', () => {
|
||||
// Exercises the `!coverageSummary` early return. runs is non-empty, but the
|
||||
// latest run checked zero resources with no scope, so coverageSummary is ''.
|
||||
const result = getPatrolFindingsEmptyState({
|
||||
filter: 'active',
|
||||
overallHealth: HEALTHY_OVERALL,
|
||||
runtimeState: 'active',
|
||||
runs: [
|
||||
makeRun({
|
||||
id: 'run-empty-coverage',
|
||||
resources_checked: 0,
|
||||
scope_resource_ids: [],
|
||||
effective_scope_resource_ids: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
title: 'No current issues',
|
||||
body: undefined,
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends a scoped coverage summary with a trailing period when the latest run covered a known scope', () => {
|
||||
// Exercises the final `return \`${coverageSummary}.\`` arm with the
|
||||
// "Checked N scoped resources" form of getPatrolRunCoverageSummary.
|
||||
const result = getPatrolFindingsEmptyState({
|
||||
filter: 'active',
|
||||
overallHealth: HEALTHY_OVERALL,
|
||||
runtimeState: 'active',
|
||||
runs: [
|
||||
makeRun({
|
||||
id: 'run-full-scope',
|
||||
resources_checked: 5,
|
||||
scope_resource_ids: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
effective_scope_resource_ids: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
title: 'No current issues',
|
||||
body: 'Checked 5 scoped resources.',
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { UpgradeDestination } from '@/utils/upgradeNavigation';
|
||||
import {
|
||||
getInProductPricingDestination,
|
||||
getPricingRouteDestination,
|
||||
handoffToExternalPricing,
|
||||
isRetiredPricingFeature,
|
||||
isSelfHostedPurchaseStartDestination,
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
resolveCanonicalSelfHostedBillingHref,
|
||||
resolveSelfHostedBillingSection,
|
||||
scopeSelfHostedBillingDestination,
|
||||
SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
|
||||
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
|
||||
SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
SELF_HOSTED_PURCHASE_START_PATH,
|
||||
} from '@/utils/pricingHandoff';
|
||||
|
||||
const PUBLIC_PRICING_URL =
|
||||
'https://pulserelay.pro/pricing?utm_source=pulse&utm_medium=app&utm_campaign=upgrade';
|
||||
|
||||
describe('pricingHandoff (branch coverage)', () => {
|
||||
describe('normalizeSettingsLikePath (via resolveSelfHostedBillingSection)', () => {
|
||||
it('returns the original input and falls through when the path trims to empty', () => {
|
||||
// Empty/whitespace inputs hit the `if (!normalized) return pathname` early return.
|
||||
// The un-trimmed value is carried forward and, being a non-billing path, resolves to 'plan'.
|
||||
expect(resolveSelfHostedBillingSection('')).toBe('plan');
|
||||
expect(resolveSelfHostedBillingSection(' ')).toBe('plan');
|
||||
});
|
||||
|
||||
it('strips a single trailing slash before matching routes', () => {
|
||||
expect(resolveSelfHostedBillingSection(`${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}/`)).toBe(
|
||||
'usage',
|
||||
);
|
||||
expect(resolveSelfHostedBillingSection(`${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}/`)).toBe(
|
||||
'plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes via the /\\+$/ replace', () => {
|
||||
expect(resolveSelfHostedBillingSection(`${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}//`)).toBe(
|
||||
'usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a single-character root path unchanged (length > 1 guard is false)', () => {
|
||||
expect(resolveSelfHostedBillingSection('/')).toBe('plan');
|
||||
});
|
||||
|
||||
it('passes a normal path straight through to the final return', () => {
|
||||
expect(resolveSelfHostedBillingSection('/totally/elsewhere')).toBe('plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSelfHostedBillingSection', () => {
|
||||
it('resolves the legacy usage and plan sub-routes directly', () => {
|
||||
expect(resolveSelfHostedBillingSection(LEGACY_SELF_HOSTED_PRO_BILLING_USAGE_ROUTE)).toBe(
|
||||
'usage',
|
||||
);
|
||||
expect(resolveSelfHostedBillingSection(LEGACY_SELF_HOSTED_PRO_BILLING_PLAN_ROUTE)).toBe(
|
||||
'plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats any unrecognized non-billing path as the plan section', () => {
|
||||
expect(resolveSelfHostedBillingSection('/dashboard')).toBe('plan');
|
||||
});
|
||||
|
||||
it('derives usage/plan from the section id hash at the canonical billing root', () => {
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-usage'),
|
||||
).toBe('usage');
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-plan'),
|
||||
).toBe('plan');
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-recovery'),
|
||||
).toBe('plan');
|
||||
});
|
||||
|
||||
it('accepts a hash without the leading "#" via normalizeHash', () => {
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(SELF_HOSTED_PRO_BILLING_ROUTE, '', 'pulse-pro-usage'),
|
||||
).toBe('usage');
|
||||
});
|
||||
|
||||
it('falls back to plan when the hash is blank/whitespace at the billing root', () => {
|
||||
expect(resolveSelfHostedBillingSection(SELF_HOSTED_PRO_BILLING_ROUTE, '', ' ')).toBe(
|
||||
'plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('derives usage/plan from query-string details at the billing root', () => {
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(
|
||||
SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
`?details=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`,
|
||||
),
|
||||
).toBe('usage');
|
||||
expect(
|
||||
resolveSelfHostedBillingSection(
|
||||
SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
`?details=${SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL}`,
|
||||
),
|
||||
).toBe('plan');
|
||||
});
|
||||
|
||||
it('defaults to plan at the legacy billing root with no disambiguating signal', () => {
|
||||
expect(resolveSelfHostedBillingSection(LEGACY_SELF_HOSTED_PRO_BILLING_ROUTE)).toBe('plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInProductPricingDestination', () => {
|
||||
it('maps known in-product feature keys to their plan/selection hrefs', () => {
|
||||
expect(getInProductPricingDestination('relay')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getInProductPricingDestination('mobile_app')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getInProductPricingDestination('long_term_metrics')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
);
|
||||
expect(getInProductPricingDestination('agent_profiles')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getInProductPricingDestination('self_hosted_plan')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
);
|
||||
expect(getInProductPricingDestination('ai_autofix')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the feature is absent/blank (normalizeFeatureKey falsy branch)', () => {
|
||||
expect(getInProductPricingDestination(null)).toBeUndefined();
|
||||
expect(getInProductPricingDestination(undefined)).toBeUndefined();
|
||||
expect(getInProductPricingDestination('')).toBeUndefined();
|
||||
expect(getInProductPricingDestination(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a feature key that is not in the catalog map', () => {
|
||||
expect(getInProductPricingDestination('definitely_not_a_feature')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRetiredPricingFeature', () => {
|
||||
it('returns false for non-retired feature keys', () => {
|
||||
expect(isRetiredPricingFeature('relay')).toBe(false);
|
||||
expect(isRetiredPricingFeature('cloud')).toBe(false);
|
||||
expect(isRetiredPricingFeature('trial_expired_x')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false (not the thrown/has branch) when the feature is absent/blank', () => {
|
||||
expect(isRetiredPricingFeature(null)).toBe(false);
|
||||
expect(isRetiredPricingFeature(undefined)).toBe(false);
|
||||
expect(isRetiredPricingFeature('')).toBe(false);
|
||||
expect(isRetiredPricingFeature(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true only for the retired trial_expired key', () => {
|
||||
expect(isRetiredPricingFeature('trial_expired')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPricingRouteDestination', () => {
|
||||
it('parses a search string without a leading "?" and still resolves in-product features', () => {
|
||||
expect(getPricingRouteDestination('feature=ai_autofix')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the canonical public pricing URL when there is no feature and no search', () => {
|
||||
expect(getPricingRouteDestination('')).toBe(PUBLIC_PRICING_URL);
|
||||
});
|
||||
|
||||
it('merges non-feature query params onto the public pricing URL', () => {
|
||||
expect(getPricingRouteDestination('?utm_content=join')).toBe(
|
||||
`${PUBLIC_PRICING_URL}&utm_content=join`,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a blank feature value and keeps non-empty params on the public URL', () => {
|
||||
// feature=&utm_content=keep -> feature is empty so it falls through to the public
|
||||
// URL branch; the empty value is skipped while utm_content is preserved.
|
||||
expect(getPricingRouteDestination('?feature=&utm_content=keep')).toBe(
|
||||
`${PUBLIC_PRICING_URL}&utm_content=keep`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCanonicalSelfHostedBillingHref', () => {
|
||||
it('returns null for non-billing paths', () => {
|
||||
expect(resolveCanonicalSelfHostedBillingHref('/dashboard')).toBeNull();
|
||||
expect(resolveCanonicalSelfHostedBillingHref('')).toBeNull();
|
||||
});
|
||||
|
||||
it('canonicalizes a counting-rules usage detail to the usage counting-rules href', () => {
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
`?details=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`,
|
||||
),
|
||||
).toBe(SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF);
|
||||
});
|
||||
|
||||
it('canonicalizes a valid plan-selection intent to the plan selection href', () => {
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
`?intent=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`,
|
||||
),
|
||||
).toBe(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF);
|
||||
});
|
||||
|
||||
it('canonicalizes a recovery detail (from search) to the plan recovery href', () => {
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
`?details=${SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL}`,
|
||||
),
|
||||
).toBe(SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF);
|
||||
});
|
||||
|
||||
it('carries a purchase arrival onto the canonical plan href', () => {
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
`?purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}`,
|
||||
),
|
||||
).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}`);
|
||||
});
|
||||
|
||||
it('defaults the bare canonical billing root to the plan href', () => {
|
||||
expect(resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE)).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores plan-only details on the usage section (usageDetail stays null)', () => {
|
||||
// recovery is a plan detail; on the usage section it must not be applied.
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
`?details=${SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL}`,
|
||||
),
|
||||
).toBe(SELF_HOSTED_PRO_BILLING_USAGE_HREF);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeSelfHostedBillingDestination', () => {
|
||||
it('returns an external destination unchanged (external early return)', () => {
|
||||
const destination: UpgradeDestination = {
|
||||
href: 'https://example.com/pricing',
|
||||
external: true,
|
||||
};
|
||||
expect(scopeSelfHostedBillingDestination(destination, 'plan')).toBe(destination);
|
||||
expect(scopeSelfHostedBillingDestination(destination, 'plan')).toStrictEqual(destination);
|
||||
});
|
||||
|
||||
it('returns an internal non-billing destination unchanged (not-a-billing-path return)', () => {
|
||||
const destination: UpgradeDestination = { href: '/dashboard', external: false };
|
||||
expect(scopeSelfHostedBillingDestination(destination, 'plan')).toBe(destination);
|
||||
expect(scopeSelfHostedBillingDestination(destination, 'plan')).toStrictEqual({
|
||||
href: '/dashboard',
|
||||
external: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites a billing-root destination to the plain usage href', () => {
|
||||
const destination: UpgradeDestination = {
|
||||
href: SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
external: false,
|
||||
};
|
||||
expect(scopeSelfHostedBillingDestination(destination, 'usage')).toStrictEqual({
|
||||
href: SELF_HOSTED_PRO_BILLING_USAGE_HREF,
|
||||
external: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves auxiliary destination fields when rewriting the href', () => {
|
||||
const destination: UpgradeDestination = {
|
||||
href: SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
external: false,
|
||||
hardNavigation: true,
|
||||
newTab: false,
|
||||
preserveOpener: true,
|
||||
};
|
||||
expect(
|
||||
scopeSelfHostedBillingDestination(destination, 'plan', {
|
||||
intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
href: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
external: false,
|
||||
hardNavigation: true,
|
||||
newTab: false,
|
||||
preserveOpener: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelfHostedPurchaseStartDestination', () => {
|
||||
it('returns false and swallows the error for a malformed non-external URL (catch branch)', () => {
|
||||
// 'ftp://[' is not http(s)/mailto so it bypasses the external guard, but it is
|
||||
// an invalid special-scheme URL and throws inside new URL(...), hitting catch.
|
||||
expect(isSelfHostedPurchaseStartDestination('ftp://[')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an internal path that is not the purchase-start path', () => {
|
||||
expect(isSelfHostedPurchaseStartDestination('/settings/elsewhere')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true and ignores query string and hash on the purchase-start path', () => {
|
||||
expect(
|
||||
isSelfHostedPurchaseStartDestination(`${SELF_HOSTED_PURCHASE_START_PATH}?feature=relay`),
|
||||
).toBe(true);
|
||||
expect(isSelfHostedPurchaseStartDestination(`${SELF_HOSTED_PURCHASE_START_PATH}#top`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for an external upgrade href (external guard)', () => {
|
||||
expect(isSelfHostedPurchaseStartDestination('https://pulserelay.pro/pricing')).toBe(false);
|
||||
expect(isSelfHostedPurchaseStartDestination('mailto:support@pulserelay.pro')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handoffToExternalPricing', () => {
|
||||
it('replaces the window location with the destination href', () => {
|
||||
// jsdom defines `window.location.replace` as a non-configurable own
|
||||
// property, so vi.spyOn cannot intercept it. `window.location` itself is
|
||||
// a configurable accessor, so swap in a stub carrying a `replace` spy and
|
||||
// restore the original descriptor afterwards.
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
const replaceSpy = vi.fn<(href: string) => void>();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { replace: replaceSpy },
|
||||
});
|
||||
try {
|
||||
handoffToExternalPricing('https://example.com/pricing');
|
||||
expect(replaceSpy).toHaveBeenCalledWith('https://example.com/pricing');
|
||||
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(window, 'location', originalDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user