diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantModelRoutes.branchcov0712b.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantModelRoutes.branchcov0712b.test.ts new file mode 100644 index 000000000..a8c7ff42d --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantModelRoutes.branchcov0712b.test.ts @@ -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'); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.branchcov0712b.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.branchcov0712b.test.ts new file mode 100644 index 000000000..f56edf020 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.branchcov0712b.test.ts @@ -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 => + 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= 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= 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'); + }); +}); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/setupCompletionModel.branchcov0712b.test.ts b/frontend-modern/src/components/SetupWizard/__tests__/setupCompletionModel.branchcov0712b.test.ts new file mode 100644 index 000000000..040317963 --- /dev/null +++ b/frontend-modern/src/components/SetupWizard/__tests__/setupCompletionModel.branchcov0712b.test.ts @@ -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 & Pick; + +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'); + }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts new file mode 100644 index 000000000..5cbf97b20 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts @@ -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 => ({ + 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 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 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 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 nameAnameB', () => { + 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']); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/patrolEmptyStatePresentation.branchcov0712b.test.ts b/frontend-modern/src/utils/__tests__/patrolEmptyStatePresentation.branchcov0712b.test.ts new file mode 100644 index 000000000..6abe62ffa --- /dev/null +++ b/frontend-modern/src/utils/__tests__/patrolEmptyStatePresentation.branchcov0712b.test.ts @@ -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 => ({ + 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', + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.branchcov0712b.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.branchcov0712b.test.ts new file mode 100644 index 000000000..843d34745 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.branchcov0712b.test.ts @@ -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); + } + } + }); + }); +});