diff --git a/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.coverage.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.coverage.test.ts new file mode 100644 index 0000000000..e5a049285d --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.coverage.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from 'vitest'; +import { + parseToolCommandPreview, + parseToolInputSummary, + pendingToolActionLabel, + pendingToolActionState, + toolValueText, +} from '../toolPresentation'; + +const summary = (record: Record, tool: string, rawInput?: string): string => + parseToolInputSummary(JSON.stringify(record), tool, rawInput); + +const readSummary = (record: Record): string => summary(record, 'pulse_read'); +const alertsSummary = (record: Record): string => summary(record, 'pulse_alerts'); + +const partialRaw = (tool: string, rawInput: string): string => + parseToolInputSummary('{}', tool, rawInput); + +const commandPreview = (input: string, tool: string, rawInput?: string): string => + parseToolCommandPreview(input, tool, rawInput); + +describe('pendingToolActionLabel', () => { + it('maps every known tool to its in-progress verb, honoring pulse_ normalization', () => { + expect(pendingToolActionLabel('pulse_run_command')).toBe('Writing command...'); + expect(pendingToolActionLabel('pulse_control')).toBe('Writing command...'); + expect(pendingToolActionLabel('pulse_read')).toBe('Preparing read...'); + expect(pendingToolActionLabel('pulse_query')).toBe('Preparing query...'); + expect(pendingToolActionLabel('pulse_fetch_url')).toBe('Fetching URL...'); + expect(pendingToolActionLabel('pulse_get_infrastructure_state')).toBe('Reading infrastructure...'); + expect(pendingToolActionLabel('pulse_get_active_alerts')).toBe('Reading alerts...'); + expect(pendingToolActionLabel('alerts')).toBe('Reading alerts...'); + expect(pendingToolActionLabel('pulse_get_metrics_history')).toBe('Reading metrics...'); + expect(pendingToolActionLabel('pulse_get_baselines')).toBe('Reading baselines...'); + expect(pendingToolActionLabel('pulse_get_patterns')).toBe('Reading patterns...'); + expect(pendingToolActionLabel('pulse_get_disk_health')).toBe('Checking disks...'); + expect(pendingToolActionLabel('pulse_get_storage')).toBe('Reading storage...'); + expect(pendingToolActionLabel('pulse_get_storage_config')).toBe('Reading storage...'); + expect(pendingToolActionLabel('pulse_get_resource_details')).toBe('Reading resource...'); + }); + + it('recognizes finding tools by substring and falls back for unknown tools', () => { + expect(pendingToolActionLabel('pulse_finding_scan')).toBe('Preparing finding...'); + expect(pendingToolActionLabel('pulse_unknown_tool')).toBe('Preparing tool...'); + }); + + it('returns the default for empty and undefined names and trims surrounding whitespace', () => { + expect(pendingToolActionLabel(undefined)).toBe('Preparing tool...'); + expect(pendingToolActionLabel('')).toBe('Preparing tool...'); + expect(pendingToolActionLabel(' ')).toBe('Preparing tool...'); + expect(pendingToolActionLabel(' pulse_query ')).toBe('Preparing query...'); + }); +}); + +describe('pendingToolActionState', () => { + it('classifies write, prepare, fetch, and check tools distinctly', () => { + expect(pendingToolActionState('pulse_run_command')).toBe('writing'); + expect(pendingToolActionState('pulse_control')).toBe('writing'); + expect(pendingToolActionState('pulse_query')).toBe('preparing'); + expect(pendingToolActionState('pulse_fetch_url')).toBe('fetching'); + expect(pendingToolActionState('pulse_get_disk_health')).toBe('checking'); + }); + + it('classifies the reading tool family as reading', () => { + expect(pendingToolActionState('pulse_read')).toBe('reading'); + expect(pendingToolActionState('pulse_get_infrastructure_state')).toBe('reading'); + expect(pendingToolActionState('pulse_get_active_alerts')).toBe('reading'); + expect(pendingToolActionState('alerts')).toBe('reading'); + expect(pendingToolActionState('pulse_get_metrics_history')).toBe('reading'); + expect(pendingToolActionState('pulse_get_baselines')).toBe('reading'); + expect(pendingToolActionState('pulse_get_patterns')).toBe('reading'); + expect(pendingToolActionState('pulse_get_storage')).toBe('reading'); + expect(pendingToolActionState('pulse_get_storage_config')).toBe('reading'); + expect(pendingToolActionState('pulse_get_resource_details')).toBe('reading'); + }); + + it('falls back to preparing for unknown and undefined tool names', () => { + expect(pendingToolActionState('pulse_unknown')).toBe('preparing'); + expect(pendingToolActionState(undefined)).toBe('preparing'); + }); +}); + +describe('toolValueText', () => { + it('returns strings as-is and empty for null or undefined', () => { + expect(toolValueText('hello')).toBe('hello'); + expect(toolValueText('')).toBe(''); + expect(toolValueText(null)).toBe(''); + expect(toolValueText(undefined)).toBe(''); + }); + + it('JSON-stringifies numbers, booleans, arrays, and objects', () => { + expect(toolValueText(42)).toBe('42'); + expect(toolValueText(true)).toBe('true'); + expect(toolValueText([1, 2])).toBe('[1,2]'); + expect(toolValueText({ a: 1 })).toBe('{"a":1}'); + }); + + it('falls back to String(value) when JSON.stringify throws on a circular reference', () => { + const circular: Record = {}; + circular.self = circular; + expect(toolValueText(circular)).toBe('[object Object]'); + }); +}); + +describe('formatAlertsInputSummary (alerts tool)', () => { + it('lists alerts by severity, with no severity, and treats whitespace severity as absent', () => { + expect(alertsSummary({ action: 'list', severity: 'critical' })).toBe('list critical alerts'); + expect(alertsSummary({ action: 'list' })).toBe('list active alerts'); + expect(alertsSummary({ action: 'list', severity: ' ' })).toBe('list active alerts'); + }); + + it('coerces a numeric severity to a string label', () => { + expect(alertsSummary({ action: 'list', severity: 5 })).toBe('list 5 alerts'); + }); + + it('summarizes get with and without a resource, and labels other actions', () => { + expect(alertsSummary({ action: 'get', resource: 'web-01' })).toBe('get alert for web-01'); + expect(alertsSummary({ action: 'get' })).toBe('get alert'); + expect(alertsSummary({ action: 'acknowledge' })).toBe('acknowledge'); + }); + + it('reads alerts when no recognizable action is present', () => { + expect(alertsSummary({ note: 'x' })).toBe('read alerts'); + }); +}); + +describe('formatStructuredInputSummary routing arms', () => { + it('returns request for an empty record with no recoverable raw input', () => { + expect(parseToolInputSummary('{}', 'pulse_read')).toBe('request'); + }); + + it('routes a non-special tool to an action label, a command label, or a generic request', () => { + expect(summary({ action: 'reboot' }, 'pulse_custom')).toBe('reboot'); + expect(summary({ command: 'restart-nginx' }, 'pulse_custom')).toBe('restart-nginx'); + expect(summary({ foo: 'bar' }, 'pulse_custom')).toBe('request'); + }); +}); + +describe('formatPartialRawInputSummary (empty record + raw input)', () => { + it('summarizes read and write commands from partial raw JSON', () => { + expect(partialRaw('pulse_read', '{"command":"df -h"}')).toBe('Inspect filesystems'); + expect(partialRaw('pulse_run_command', '{"command":"reboot"}')).toBe('Run command'); + expect(partialRaw('pulse_control', '{"command":"reboot"}')).toBe('Run command'); + }); + + it('summarizes read actions for file, tail, find, and logs', () => { + expect(partialRaw('pulse_read', '{"action":"file","path":"/etc/hosts"}')).toBe( + 'read /etc/hosts', + ); + expect(partialRaw('pulse_read', '{"action":"tail","path":"/var/log/syslog"}')).toBe( + 'tail /var/log/syslog', + ); + expect(partialRaw('pulse_read', '{"action":"find","pattern":"ERROR"}')).toBe('find "ERROR"'); + expect(partialRaw('pulse_read', '{"action":"logs"}')).toBe('read logs'); + }); + + it('appends a target suffix for partial read file actions', () => { + expect( + partialRaw('pulse_read', '{"action":"file","path":"/etc/hosts","target_host":"web-01"}'), + ).toBe('read /etc/hosts on web-01'); + }); + + it('summarizes query actions for search, list, and other verbs', () => { + expect(partialRaw('pulse_query', '{"action":"search","query":"web-01"}')).toBe( + 'search "web-01"', + ); + expect(partialRaw('pulse_query', '{"action":"list"}')).toBe('list resources'); + expect(partialRaw('pulse_query', '{"action":"rebuild"}')).toBe('rebuild'); + }); + + it('surfaces an action label for unrelated tools and reports receiving input otherwise', () => { + expect(partialRaw('pulse_custom', '{"action":"reboot"}')).toBe('reboot'); + expect(partialRaw('pulse_custom', '{"note":"x"}')).toBe('receiving input'); + }); +}); + +describe('shellCommandIntentLabel (read exec commands)', () => { + it('labels device, disk, zfs, log, service, container, k8s, proxmox, and network intents', () => { + expect(readSummary({ action: 'exec', command: 'lsblk' })).toBe('Inspect devices'); + expect(readSummary({ action: 'exec', command: 'smartctl --scan' })).toBe('Check disk health'); + expect(readSummary({ action: 'exec', command: 'zpool status' })).toBe('Inspect ZFS storage'); + expect(readSummary({ action: 'exec', command: 'journalctl -u nginx' })).toBe('Read logs'); + expect(readSummary({ action: 'exec', command: 'systemctl status nginx' })).toBe( + 'Check service status', + ); + expect(readSummary({ action: 'exec', command: 'docker ps' })).toBe('Inspect containers'); + expect(readSummary({ action: 'exec', command: 'kubectl get pods' })).toBe( + 'Inspect Kubernetes resources', + ); + expect(readSummary({ action: 'exec', command: 'qm list' })).toBe('Inspect Proxmox resources'); + expect(readSummary({ action: 'exec', command: 'ping 10.0.0.1' })).toBe('Check network state'); + }); + + it('appends a target suffix to a read intent', () => { + expect(readSummary({ action: 'exec', command: 'lsblk', target_host: 'web-01' })).toBe( + 'Inspect devices on web-01', + ); + }); + + it('falls back to a generic read-only label for unrecognized commands', () => { + expect(readSummary({ action: 'exec', command: 'echo hello' })).toBe('Run read-only command'); + }); +}); + +describe('parseFunctionStyleToolInput', () => { + it('parses a well-formed multi-argument call with quoted string values', () => { + expect(parseToolInputSummary('read(action="file", path="/etc/hosts")', 'pulse_read')).toBe( + 'read /etc/hosts', + ); + }); + + it('types unquoted scalar values as boolean, number, null, and bareword', () => { + expect(parseToolInputSummary('query(action=topology, summary_only=true)', 'pulse_query')).toBe( + 'topology summary', + ); + expect(parseToolInputSummary('query(action=topology, summary_only=false)', 'pulse_query')).toBe( + 'topology', + ); + expect(parseToolInputSummary('query(action=get, resource_id=101)', 'pulse_query')).toBe( + 'get 101', + ); + expect(parseToolInputSummary('query(action=get, resource_id=vm101)', 'pulse_query')).toBe( + 'get vm101', + ); + expect(parseToolInputSummary('query(action=list, type=null)', 'pulse_query')).toBe( + 'list resources', + ); + expect(parseToolInputSummary('query(action=list, type=vm)', 'pulse_query')).toBe('list vm'); + }); + + it('returns null for input without a function-call shape so the caller echoes the label', () => { + expect(parseToolInputSummary('plain text', 'pulse_read')).toBe('plain text'); + }); + + it('yields no structured result when the body has no parseable key', () => { + expect(parseToolInputSummary('read(123)', 'pulse_read')).toBe('read(123)'); + expect(parseToolInputSummary('read(', 'pulse_read')).toBe('read('); + }); + + it('skips a leading comma before the first argument', () => { + expect(parseToolInputSummary('read(,action="file")', 'pulse_read')).toBe('read file'); + }); +}); + +describe('parseQuotedValue escape handling', () => { + it('unescapes embedded quotes inside a quoted command value', () => { + expect( + commandPreview('run_command(command="echo \\"hi\\"")', 'pulse_run_command'), + ).toBe('$ echo "hi"'); + }); + + it('rescues earlier args when a quote is left unterminated or a comma is missing', () => { + expect( + parseToolInputSummary('read(action="file" path="/x")', 'pulse_read'), + ).toBe('read file'); + expect( + parseToolInputSummary('read(action="file", path="unterminated)', 'pulse_read'), + ).toBe('read unterminated'); + }); + + it('stops cleanly at an early closing paren and preserves a trailing-backslash partial value', () => { + expect(parseToolInputSummary('read(action="file"))', 'pulse_read')).toBe('read file'); + expect(commandPreview('run_command(command="df\\', 'pulse_run_command')).toBe('$ df'); + }); + + it('treats a trailing backslash in a full call as a failed quote, rescued by the partial pass', () => { + expect(parseToolInputSummary('read(action="abc\\)', 'pulse_read')).toBe('abc'); + }); +}); + +describe('parseStructuredInputRecord', () => { + it('rejects JSON arrays and primitives, then yields a label fallback', () => { + expect(parseToolInputSummary('[1,2,3]', 'pulse_read')).toBe('[1,2,3]'); + expect(parseToolInputSummary('42', 'pulse_read')).toBe('42'); + }); + + it('rescues a non-placeholder summary from raw input when the parsed input is a placeholder', () => { + expect( + parseToolInputSummary(JSON.stringify({ foo: 'bar' }), 'pulse_custom', '{"action":"reboot"}'), + ).toBe('reboot'); + }); +}); + +describe('commandPreviewValue', () => { + it('returns an empty preview for a whitespace-only command', () => { + expect(commandPreview(JSON.stringify({ command: ' ' }), 'pulse_run_command')).toBe(''); + }); + + it('returns a short redacted command unchanged', () => { + expect(commandPreview(JSON.stringify({ command: 'df -h' }), 'pulse_run_command')).toBe( + '$ df -h', + ); + }); + + it('redacts bearer tokens wired through the preview', () => { + const bearerToken = 'secret-token'; + expect( + commandPreview( + JSON.stringify({ command: `curl -H "Authorization: Bearer ${bearerToken}" https://x` }), + 'pulse_run_command', + ), + ).toBe('$ curl -H "Authorization: Bearer [redacted-secret]" https://x'); + }); + + it('truncates a long command with an ellipsis', () => { + const longCommand = `echo ${'x'.repeat(150)}`; + const preview = commandPreview(JSON.stringify({ command: longCommand }), 'pulse_run_command'); + expect(preview).toMatch(/^\$ echo x{135}\.{3}$/); + }); +}); + +describe('partialCommandPreview', () => { + it('returns empty for tools that are not command tools', () => { + expect(commandPreview('{"command":"ls"}', 'pulse_query')).toBe(''); + }); + + it('returns empty when no command field is present', () => { + expect(commandPreview('{"foo":"bar"}', 'pulse_run_command')).toBe(''); + }); + + it('extracts a command from partial JSON via the primary and alias keys', () => { + expect(commandPreview('{"command":"df"', 'pulse_run_command')).toBe('$ df'); + expect(commandPreview('{"cmd":"ls"', 'pulse_run_command')).toBe('$ ls'); + }); + + it('falls back to 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/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts new file mode 100644 index 0000000000..28a6d3dd21 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts @@ -0,0 +1,601 @@ +import { describe, expect, it } from 'vitest'; +import { + buildVMwareDetailSections, + buildVMwareDetailsSummary, +} from '@/components/Infrastructure/resourceDetailDrawerVmwareModel'; +import type { DetailRow } from '@/components/shared/detailSectionModel'; +import type { + ResourceVMwareHardware, + ResourceVMwareMeta, + ResourceVMwareNetworkAdapter, + ResourceVMwareSnapshot, + ResourceVMwareTools, + ResourceVMwareVirtualDisk, +} from '@/types/resource'; + +// Every target helper (formatVirtualDiskAddress, adapterNetworkName, etc.) is +// module-private, so each case drives it through the two public entry points +// (buildVMwareDetailsSummary / buildVMwareDetailSections) and asserts on the +// rendered summary string or section/row shape. Fixtures are built minimal so +// each branch is exercised in isolation. + +type Sections = ReturnType; + +const findSection = (sections: Sections, label: string) => + sections.find((section) => section.label === label); + +const findRow = (sections: Sections, sectionLabel: string, rowLabel: string): DetailRow | undefined => + findSection(sections, sectionLabel)?.rows.find((row) => row.label === rowLabel); + +const vmSections = (vmware?: Partial): Sections => + buildVMwareDetailSections('vm', (vmware ?? {}) as ResourceVMwareMeta); + +const networkRow = (adapter: Partial): DetailRow | undefined => + findSection(vmSections({ networkAdapters: [adapter as ResourceVMwareNetworkAdapter] }), 'Network') + ?.rows[0]; + +const diskRow = (disk: Partial): DetailRow | undefined => + findSection(vmSections({ virtualDisks: [disk as ResourceVMwareVirtualDisk] }), 'Virtual disks') + ?.rows[0]; + +const snapshotRows = (snapshots: ResourceVMwareSnapshot[]): DetailRow[] => + findSection(vmSections({ snapshotTree: snapshots }), 'Snapshot tree')?.rows ?? []; + +const hardwareRow = (vmware: Partial, label: string): DetailRow | undefined => + findRow(vmSections(vmware), 'Virtual hardware', label); + +const toolsRow = (vmware: Partial, label: string): DetailRow | undefined => + findRow(vmSections(vmware), 'VMware Tools', label); + +const summary = (vmware?: Partial): string | null => + buildVMwareDetailsSummary('vm', (vmware ?? {}) as ResourceVMwareMeta); + +const summaryWithTools = (tools: Partial): string | null => + summary({ tools }); + +const summaryWithHardware = (hardware: Partial): string | null => + summary({ hardware }); + +describe('formatVirtualDiskAddress (via Virtual disks section)', () => { + it('formats a SATA bus:unit address', () => { + expect(diskRow({ type: 'SATA', sataBus: 1, sataUnit: 2, capacityBytes: 10_737_418_240 })?.value).toBe( + 'SATA 1:2 · 10 GB', + ); + }); + + it('formats an NVMe bus:unit address', () => { + expect(diskRow({ type: 'NVME', nvmeBus: 0, nvmeUnit: 3, capacityBytes: 10_737_418_240 })?.value).toBe( + 'NVMe 0:3 · 10 GB', + ); + }); + + it('formats an IDE primary master address', () => { + expect( + diskRow({ type: 'IDE', idePrimary: true, ideMaster: true, capacityBytes: 10_737_418_240 })?.value, + ).toBe('IDE primary master · 10 GB'); + }); + + it('formats an IDE secondary slave address', () => { + expect( + diskRow({ type: 'IDE', idePrimary: false, ideMaster: false, capacityBytes: 10_737_418_240 }) + ?.value, + ).toBe('IDE secondary slave · 10 GB'); + }); + + it('falls back to the bare type when SCSI bus fields are missing', () => { + expect(diskRow({ type: 'SCSI', capacityBytes: 10_737_418_240 })?.value).toBe('SCSI · 10 GB'); + }); + + it('falls back to the uppercased type for an unrecognized controller', () => { + expect(diskRow({ type: 'custom', capacityBytes: 10_737_418_240 })?.value).toBe('CUSTOM · 10 GB'); + }); +}); + +describe('buildVMwareDetailsSummary', () => { + it('returns null when vmware is undefined', () => { + expect(buildVMwareDetailsSummary('vm', undefined)).toBeNull(); + }); + + it('falls back to vcenterHost when connectionName is absent', () => { + expect(buildVMwareDetailsSummary('vm', { vcenterHost: 'vc-01.lab' })).toBe( + 'vc-01.lab · Read-only vCenter context', + ); + }); + + it('omits the connection prefix when both connectionName and vcenterHost are blank', () => { + expect(buildVMwareDetailsSummary('vm', {})).toBe('Read-only vCenter context'); + }); + + it('uses the explicit snapshotCount number in preference to the tree', () => { + expect( + buildVMwareDetailsSummary('vm', { + snapshotCount: 3, + snapshotTree: [{ snapshot: 's-1' }], + }), + ).toBe('Read-only vCenter context · 3 snapshots'); + }); + + it('counts the snapshot tree when snapshotCount is not a number', () => { + expect( + buildVMwareDetailsSummary('vm', { + snapshotTree: [ + { snapshot: 's-1', children: [{ snapshot: 's-2' }, { snapshot: 's-3' }] }, + ], + }), + ).toBe('Read-only vCenter context · 3 snapshots'); + }); + + it('clamps a negative snapshotCount to zero and omits the snapshot part', () => { + expect(buildVMwareDetailsSummary('vm', { snapshotCount: -1 })).toBe( + 'Read-only vCenter context', + ); + }); + + it('includes vNIC and disk counts for a vm', () => { + expect( + buildVMwareDetailsSummary('vm', { + networkAdapters: [{ nic: '1' }], + virtualDisks: [{ disk: '1', type: 'SCSI' }], + }), + ).toBe('Read-only vCenter context · 1 vNIC · 1 disk'); + }); + + it('includes network host/vm counts via names', () => { + expect( + buildVMwareDetailsSummary('network', { + networkHostNames: ['h1', 'h2'], + networkVmNames: ['vm1'], + }), + ).toBe('Read-only vCenter context · 2 hosts · 1 VM'); + }); + + it('falls back to network host/vm ids when names are absent', () => { + expect( + buildVMwareDetailsSummary('network', { + networkHostIds: ['h-1', 'h-2', 'h-3'], + networkVmIds: ['vm-1', 'vm-2'], + }), + ).toBe('Read-only vCenter context · 3 hosts · 2 VMs'); + }); + + it('includes alarm and task counts', () => { + expect( + buildVMwareDetailsSummary('vm', { activeAlarmCount: 2, recentTaskCount: 5 }), + ).toBe('Read-only vCenter context · 2 alarms · 5 tasks'); + }); + + it('omits hardware and tools parts for a non-vm resource type', () => { + expect( + buildVMwareDetailsSummary('datastore', { hardware: { version: 'VMX_19' } }), + ).toBe('Read-only vCenter context'); + }); +}); + +describe('hardwareSummary (via buildVMwareDetailsSummary)', () => { + it('surfaces a non-default upgrade status as "Hardware "', () => { + expect(summaryWithHardware({ upgradeStatus: 'FAILED' })).toBe( + 'Read-only vCenter context · Hardware failed', + ); + }); + + it('falls back to the hardware version when upgrade status is NONE', () => { + expect(summaryWithHardware({ version: 'VMX_17', upgradeStatus: 'NONE' })).toBe( + 'Read-only vCenter context · VMX 17', + ); + }); + + it('contributes no hardware part when hardware is absent', () => { + expect(summary()).toBe('Read-only vCenter context'); + }); +}); + +describe('toolsSummary (via buildVMwareDetailsSummary)', () => { + it('surfaces a non-current version status', () => { + expect(summaryWithTools({ versionStatus: 'OUT_OF_DATE' })).toBe( + 'Read-only vCenter context · Tools out of date', + ); + }); + + it('surfaces the run state when the version status is current', () => { + expect(summaryWithTools({ versionStatus: 'CURRENT', runState: 'NOT_RUNNING' })).toBe( + 'Read-only vCenter context · Tools not running', + ); + }); + + it('contributes no tools part when there is no actionable state', () => { + expect(summaryWithTools({ versionStatus: 'OK' })).toBe('Read-only vCenter context'); + }); +}); + +describe('adapterNetworkName (via Network adapter row value)', () => { + it('prefers networkName', () => { + expect(networkRow({ networkName: 'VM Network', networkId: 'network-9' })?.value).toBe( + 'VM Network', + ); + }); + + it('falls back to networkId', () => { + expect(networkRow({ networkId: 'network-101' })?.value).toBe('network-101'); + }); + + it('falls back to opaqueNetworkId', () => { + expect(networkRow({ opaqueNetworkId: 'opaque-5' })?.value).toBe('opaque-5'); + }); + + it('falls back to hostDevice', () => { + expect(networkRow({ hostDevice: 'vmnic0' })?.value).toBe('vmnic0'); + }); + + it('falls back to backingType', () => { + expect(networkRow({ backingType: 'DVS' })?.value).toBe('DVS'); + }); + + it('omits the network part when no network field is set', () => { + expect(networkRow({ type: 'VMXNET3' })?.value).toBe('VMXNET3'); + }); +}); + +describe('adapterConnectionLabel (via Network adapter row value)', () => { + it('emits "starts connected" / "does not start connected"', () => { + expect(networkRow({ startConnected: true })?.value).toBe('starts connected'); + expect(networkRow({ startConnected: false })?.value).toBe('does not start connected'); + }); + + it('emits "guest control" / "no guest control"', () => { + expect(networkRow({ allowGuestControl: true })?.value).toBe('guest control'); + expect(networkRow({ allowGuestControl: false })?.value).toBe('no guest control'); + }); + + it('emits only the state when the toggles are undefined', () => { + expect(networkRow({ state: 'CONNECTED' })?.value).toBe('CONNECTED'); + }); + + it('joins all three connection parts with a separator', () => { + expect( + networkRow({ state: 'DISCONNECTED', startConnected: false, allowGuestControl: false })?.value, + ).toBe('DISCONNECTED · does not start connected · no guest control'); + }); +}); + +describe('adapterDisplayName (via Network adapter row label)', () => { + // A type is carried so the row value is non-empty and makeDetailRow keeps + // the row, isolating the display-name fallback logic to the label field. + it('prefers label', () => { + expect(networkRow({ label: 'eth0', nic: '4000', type: 'VMXNET3' })?.label).toBe('eth0'); + }); + + it('falls back to nic', () => { + expect(networkRow({ nic: '4000', type: 'VMXNET3' })?.label).toBe('4000'); + }); + + it('falls back to macAddress', () => { + expect(networkRow({ macAddress: 'AA:BB:CC' })?.label).toBe('AA:BB:CC'); + }); + + it('defaults to "Network adapter" when nothing identifies the adapter', () => { + expect(networkRow({ type: 'VMXNET3' })?.label).toBe('Network adapter'); + }); +}); + +describe('adapterTone (via Network adapter row tone)', () => { + it('is warning when state is not_connected', () => { + expect(networkRow({ state: 'not_connected', type: 'VMXNET3' })?.tone).toBe('warning'); + }); + + it('is default for a connected adapter', () => { + expect(networkRow({ state: 'CONNECTED', type: 'VMXNET3' })?.tone).toBe('default'); + }); + + it('is default when state is absent', () => { + expect(networkRow({ type: 'VMXNET3' })?.tone).toBe('default'); + }); +}); + +describe('hardwareRows (via Virtual hardware section)', () => { + it('drops the Virtual hardware section when hardware is absent', () => { + expect(findSection(vmSections({ cpuCount: 4 }), 'Virtual hardware')).toBeUndefined(); + }); + + it('surfaces instant clone frozen as a warning row', () => { + expect(hardwareRow({ hardware: { instantCloneFrozen: true } }, 'Instant clone frozen')).toEqual({ + label: 'Instant clone frozen', + value: 'Yes', + tone: 'warning', + }); + }); + + it('surfaces enter setup mode as a warning row', () => { + expect(hardwareRow({ hardware: { enterSetupMode: true } }, 'Enter setup mode')).toEqual({ + label: 'Enter setup mode', + value: 'Yes', + tone: 'warning', + }); + }); + + it('surfaces an upgrade error message as a warning row', () => { + expect(hardwareRow({ hardware: { upgradeErrorMessage: 'timed out' } }, 'Upgrade error')).toEqual({ + label: 'Upgrade error', + value: 'timed out', + tone: 'warning', + }); + }); + + it('omits the upgrade status row when the status is NONE/OK', () => { + const vmware = { hardware: { version: 'VMX_19', upgradeStatus: 'NONE' } }; + expect(hardwareRow(vmware, 'Upgrade status')).toBeUndefined(); + expect(hardwareRow(vmware, 'Hardware version')?.value).toBe('VMX 19'); + }); + + it('omits the instant clone / setup mode rows when the flags are false', () => { + const vmware = { hardware: { instantCloneFrozen: false, enterSetupMode: false } }; + expect(hardwareRow(vmware, 'Instant clone frozen')).toBeUndefined(); + expect(hardwareRow(vmware, 'Enter setup mode')).toBeUndefined(); + }); +}); + +describe('formatMiB (via Memory size row)', () => { + const memoryRow = (memorySizeMib?: number): DetailRow | undefined => + hardwareRow({ hardware: { version: 'VMX_10' }, memorySizeMib }, 'Memory size'); + + it('formats 1024 MiB as 1 GB', () => { + expect(memoryRow(1024)?.value).toBe('1 GB'); + }); + + it('formats zero as "0 B"', () => { + expect(memoryRow(0)?.value).toBe('0 B'); + }); + + it('omits the row when memorySizeMib is undefined', () => { + expect(memoryRow(undefined)).toBeUndefined(); + }); + + it('omits the row for a negative value', () => { + expect(memoryRow(-512)).toBeUndefined(); + }); + + it('omits the row for NaN', () => { + expect(memoryRow(Number.NaN)).toBeUndefined(); + }); +}); + +describe('toolsRows (via VMware Tools section)', () => { + it('drops the VMware Tools section when tools is absent', () => { + expect(findSection(vmSections({}), 'VMware Tools')).toBeUndefined(); + }); + + it('flags a non-running run state with a warning tone', () => { + expect(toolsRow({ tools: { runState: 'NOT_RUNNING' } }, 'Run state')).toEqual({ + label: 'Run state', + value: 'Not Running', + tone: 'warning', + }); + }); + + it('keeps the default tone for STARTED', () => { + expect(toolsRow({ tools: { runState: 'STARTED' } }, 'Run state')).toEqual({ + label: 'Run state', + value: 'Started', + tone: 'default', + }); + }); + + it('surfaces a non-current version status with a warning tone', () => { + expect(toolsRow({ tools: { versionStatus: 'OUT_OF_DATE' } }, 'Version status')).toEqual({ + label: 'Version status', + value: 'Out Of Date', + tone: 'warning', + }); + }); + + it('surfaces the last install error as a warning row', () => { + expect(toolsRow({ tools: { errorMessage: 'boom' } }, 'Last install error')).toEqual({ + label: 'Last install error', + value: 'boom', + tone: 'warning', + }); + }); +}); + +describe('virtualDiskDisplayName (via Virtual disks row label)', () => { + // A SCSI address is carried so the row value is non-empty and makeDetailRow + // keeps the row, isolating the display-name fallback to the label field. + it('prefers label', () => { + expect( + diskRow({ label: 'Hard disk 1', disk: '2000', type: 'SCSI', scsiBus: 0, scsiUnit: 0 })?.label, + ).toBe('Hard disk 1'); + }); + + it('falls back to the disk id', () => { + expect(diskRow({ disk: '2000', type: 'SCSI', scsiBus: 0, scsiUnit: 0 })?.label).toBe('2000'); + }); + + it('defaults to "Virtual disk" when neither is set', () => { + expect(diskRow({ type: 'SCSI', scsiBus: 0, scsiUnit: 0 })?.label).toBe('Virtual disk'); + }); +}); + +describe('formatBoolLabel (via State section)', () => { + const sharedRow = (vmware: Partial): DetailRow | undefined => + findRow(vmSections(vmware), 'State', 'Shared access'); + + const accessibleRow = (vmware: Partial): DetailRow | undefined => + findRow(vmSections(vmware), 'State', 'Accessible'); + + it('renders "Yes" for true (Shared access)', () => { + expect(sharedRow({ multipleHostAccess: true })?.value).toBe('Yes'); + }); + + it('renders "No" for false (Shared access)', () => { + expect(sharedRow({ multipleHostAccess: false })?.value).toBe('No'); + }); + + it('drops the row for undefined (Shared access)', () => { + expect(sharedRow({})).toBeUndefined(); + }); + + it('flags an inaccessible datastore with a warning tone', () => { + expect(accessibleRow({ datastoreAccessible: false })).toEqual({ + label: 'Accessible', + value: 'No', + tone: 'warning', + }); + }); + + it('renders an accessible datastore as "Yes" with the default tone', () => { + expect(accessibleRow({ datastoreAccessible: true })).toEqual({ + label: 'Accessible', + value: 'Yes', + tone: 'default', + }); + }); +}); + +describe('getStatusTone (via Overall status row)', () => { + // null is deliberately fed to exercise getStatusTone's null branch; the field + // itself is typed string | undefined, so the value is cast to the field type. + const statusRow = (overallStatus?: string | null): DetailRow | undefined => + findRow( + vmSections({ overallStatus: overallStatus as ResourceVMwareMeta['overallStatus'] }), + 'State', + 'Overall status', + ); + + it('maps red to warning', () => { + expect(statusRow('red')).toEqual({ label: 'Overall status', value: 'red', tone: 'warning' }); + }); + + it('maps any other non-empty status to accent', () => { + expect(statusRow('yellow')).toEqual({ + label: 'Overall status', + value: 'yellow', + tone: 'accent', + }); + }); + + it('drops the row for an empty status (default tone branch)', () => { + expect(statusRow('')).toBeUndefined(); + }); + + it('drops the row for a null status (default tone branch)', () => { + expect(statusRow(null)).toBeUndefined(); + }); +}); + +describe('vmwareEntityLabel (via Entity row)', () => { + const entityRow = (entityType?: string): DetailRow | undefined => + findRow(vmSections({ entityType }), 'State', 'Entity'); + + it.each<[string, string]>([ + ['host', 'Host'], + ['hostsystem', 'Host'], + ['HOSTSYSTEM', 'Host'], + ['vm', 'VM'], + ['virtualmachine', 'VM'], + ['datastore', 'Datastore'], + ['network', 'Network'], + ])('maps entity type "%s" to "%s"', (entityType, expected) => { + expect(entityRow(entityType)?.value).toBe(expected); + }); + + it('passes an unrecognized entity type through verbatim', () => { + expect(entityRow('resourcePool')?.value).toBe('resourcePool'); + }); + + it('drops the Entity row when entityType is blank', () => { + expect(entityRow(' ')).toBeUndefined(); + }); +}); + +describe('formatSnapshotDate (via Snapshot tree rows)', () => { + it('omits the date when createdAt is undefined', () => { + expect(snapshotRows([{ snapshot: 's-1', state: 'poweredOn' }])[0]?.value).toBe('poweredOn'); + }); + + it('returns the raw string for an unparseable date', () => { + expect(snapshotRows([{ snapshot: 's-1', createdAt: 'not-a-date' }])[0]?.value).toBe('not-a-date'); + }); + + it('formats a valid ISO timestamp in UTC', () => { + expect(snapshotRows([{ snapshot: 's-1', createdAt: '2026-01-05T09:30:00Z' }])[0]?.value).toBe( + '2026-01-05 09:30 UTC', + ); + }); +}); + +describe('snapshotDisplayName (via Snapshot tree row label)', () => { + // Each fixture carries a state so the row value is non-empty and the row + // survives makeDetailRow, isolating the display-name fallback logic. + const label = (snapshot: ResourceVMwareSnapshot): string | undefined => + snapshotRows([{ state: 'poweredOn', ...snapshot }])[0]?.label; + + it('prefers name', () => { + expect(label({ name: 'pre-upgrade', snapshot: 's-1' })).toBe('pre-upgrade'); + }); + + it('falls back to the snapshot ref', () => { + expect(label({ snapshot: 'snapshot-201' })).toBe('snapshot-201'); + }); + + it('falls back to "Snapshot " when only a numeric id is set', () => { + expect(label({ id: 7 })).toBe('Snapshot 7'); + }); + + it('falls back to "Snapshot" when nothing identifies it', () => { + expect(label({})).toBe('Snapshot'); + }); +}); + +describe('snapshotValue (via Snapshot tree row value)', () => { + const value = (snapshot: ResourceVMwareSnapshot): string | undefined => + snapshotRows([snapshot])[0]?.value; + + it('joins current marker, state, date and quiesced', () => { + expect( + value({ + current: true, + state: 'poweredOn', + createdAt: '2026-02-03T00:00:00Z', + quiesced: true, + }), + ).toBe('current · poweredOn · 2026-02-03 00:00 UTC · quiesced'); + }); + + it('emits "not quiesced" for a false quiesced flag', () => { + expect(value({ state: 'poweredOff', quiesced: false })).toBe('poweredOff · not quiesced'); + }); + + it('omits the quiesced part when it is undefined', () => { + expect(value({ state: 'poweredOn' })).toBe('poweredOn'); + }); + + it('appends the description', () => { + expect(value({ state: 'poweredOn', description: 'pre-patch' })).toBe('poweredOn · pre-patch'); + }); +}); + +describe('flattenSnapshotRows (via Snapshot tree section)', () => { + it('drops the section when there are no snapshots', () => { + expect(findSection(vmSections({ snapshotTree: [] }), 'Snapshot tree')).toBeUndefined(); + }); + + it('prefixes nested children by depth', () => { + expect( + snapshotRows([ + { + name: 'root', + state: 'poweredOn', + children: [{ name: 'child', state: 'poweredOn' }], + }, + ]) + .map((row) => row.label), + ).toEqual(['root', '- child']); + }); + + it('marks the current snapshot with an accent tone', () => { + expect(snapshotRows([{ name: 'now', state: 'poweredOn', current: true }])[0]).toEqual({ + label: 'now', + value: 'current · poweredOn', + tone: 'accent', + }); + }); +});