diff --git a/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov0717.test.ts b/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov0717.test.ts new file mode 100644 index 0000000000..311b44902e --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov0717.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + hasContainerUpdate, + hasContainerUpdateError, + hasContainerUpdateCurrent, + getContainerUpdateErrorTooltip, + getContainerUpdateCurrentTooltip, + getContainerUpdateBadgeTooltip, + getUpdateIconTooltip, + getUpdateButtonClass, + getUpdateButtonLabel, + getUpdateButtonTooltip, + type UpdateState, +} from '@/components/shared/containerUpdateBadgeModel'; +import type { DockerContainerUpdateStatus } from '@/types/api'; + +// Branch-coverage suite for every exported helper in +// containerUpdateBadgeModel.ts. Each `it()` pins a concrete return value +// (exact string / boolean / class string) so it locks the precise branch +// taken — no truthiness-only checks, no source-string echoes, no snapshots. +// +// `UPDATE_BUTTON_BASE_CLASS` is a module-private (non-exported) const, so its +// literal value is inlined here as `BASE_CLASS` to assert the full produced +// class string verbatim. This is an observable runtime value, not an +// implementation-detail import. +const BASE_CLASS = + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-all'; + +// A digest long enough that slice(0, 12) and slice(0, 19) both truncate. +// Pre-computed truncations (verified, not recomputed via slice in the test): +// slice(0, 12) -> 'sha256:01234' +// slice(0, 19) -> 'sha256:0123456789ab' +const LONG_DIGEST = 'sha256:0123456789abcdefghijklmnopqrstuvwxyz'; + +function makeStatus( + overrides: Partial = {}, +): DockerContainerUpdateStatus { + return { + updateAvailable: false, + lastChecked: 0, + ...overrides, + }; +} + +describe('containerUpdateBadgeModel.branchcov', () => { + describe('hasContainerUpdate', () => { + it('returns true when updateStatus.updateAvailable === true', () => { + expect(hasContainerUpdate(makeStatus({ updateAvailable: true }))).toBe(true); + }); + + it('returns false when updateStatus.updateAvailable === false', () => { + expect(hasContainerUpdate(makeStatus({ updateAvailable: false }))).toBe(false); + }); + + it('returns false when updateStatus is undefined (optional-chain arm)', () => { + expect(hasContainerUpdate(undefined)).toBe(false); + }); + + it('returns false when updateAvailable is absent (loose-equality undefined arm)', () => { + // `updateStatus?.updateAvailable === true` uses strict equality, so a + // missing field (undefined) must NOT be treated as an update. + const partial = { lastChecked: 1 } as DockerContainerUpdateStatus; + expect(hasContainerUpdate(partial)).toBe(false); + }); + }); + + describe('hasContainerUpdateError', () => { + it('returns true when updateStatus.error is a non-empty string', () => { + expect(hasContainerUpdateError(makeStatus({ error: 'boom' }))).toBe(true); + }); + + it('returns false when error is absent (Boolean(undefined) arm)', () => { + expect(hasContainerUpdateError(makeStatus({}))).toBe(false); + }); + + it('returns false when updateStatus is undefined (optional-chain arm)', () => { + expect(hasContainerUpdateError(undefined)).toBe(false); + }); + + it('returns false when error is an empty string (Boolean("") arm)', () => { + // Boolean('') === false — pins the falsy-string edge of Boolean(). + expect(hasContainerUpdateError(makeStatus({ error: '' }))).toBe(false); + }); + }); + + describe('hasContainerUpdateCurrent', () => { + it('returns true when updateAvailable === false and there is no error', () => { + expect(hasContainerUpdateCurrent(makeStatus({ updateAvailable: false }))).toBe(true); + }); + + it('returns false when updateAvailable === true (left operand of && is false)', () => { + expect(hasContainerUpdateCurrent(makeStatus({ updateAvailable: true }))).toBe(false); + }); + + it('returns false when an error is present alongside updateAvailable === false (right operand of && is false)', () => { + expect( + hasContainerUpdateCurrent(makeStatus({ updateAvailable: false, error: 'x' })), + ).toBe(false); + }); + + it('returns false when updateStatus is undefined (optional-chain / left-operand arm)', () => { + expect(hasContainerUpdateCurrent(undefined)).toBe(false); + }); + + it('returns false when updateAvailable is absent (strict === false fails)', () => { + const partial = { lastChecked: 1 } as DockerContainerUpdateStatus; + expect(hasContainerUpdateCurrent(partial)).toBe(false); + }); + }); + + describe('getContainerUpdateErrorTooltip', () => { + it('uses the provided error string (|| left arm)', () => { + expect(getContainerUpdateErrorTooltip(makeStatus({ error: 'registry 500' }))).toBe( + 'Update check failed: registry 500', + ); + }); + + it('falls back to "Unknown error" when error is undefined (|| right arm)', () => { + expect(getContainerUpdateErrorTooltip(makeStatus({}))).toBe( + 'Update check failed: Unknown error', + ); + }); + + it('falls back to "Unknown error" when updateStatus itself is undefined', () => { + expect(getContainerUpdateErrorTooltip(undefined)).toBe( + 'Update check failed: Unknown error', + ); + }); + + it('falls back to "Unknown error" when error is an empty string (|| right arm, falsy string)', () => { + // `'' || 'Unknown error'` evaluates to the right operand. + expect(getContainerUpdateErrorTooltip(makeStatus({ error: '' }))).toBe( + 'Update check failed: Unknown error', + ); + }); + }); + + describe('getContainerUpdateCurrentTooltip', () => { + it('returns the bare "Image is current" message when currentDigest is absent (early-return arm)', () => { + expect(getContainerUpdateCurrentTooltip(makeStatus({}))).toBe('Image is current'); + }); + + it('returns the bare "Image is current" message when updateStatus is undefined', () => { + expect(getContainerUpdateCurrentTooltip(undefined)).toBe('Image is current'); + }); + + it('returns the bare "Image is current" message when currentDigest is an empty string', () => { + // `!updateStatus?.currentDigest` is true for '' — pins the falsy-string edge. + expect(getContainerUpdateCurrentTooltip(makeStatus({ currentDigest: '' }))).toBe( + 'Image is current', + ); + }); + + it('appends a 12-char digest preview when currentDigest is present', () => { + expect( + getContainerUpdateCurrentTooltip(makeStatus({ currentDigest: LONG_DIGEST })), + ).toBe('Image is current\nDigest: sha256:01234...'); + }); + + it('uses the full digest when it is shorter than 12 chars (slice no-op arm)', () => { + expect( + getContainerUpdateCurrentTooltip(makeStatus({ currentDigest: 'abc' })), + ).toBe('Image is current\nDigest: abc...'); + }); + }); + + describe('getContainerUpdateBadgeTooltip', () => { + it('renders "unknown" for both digests when updateStatus is undefined', () => { + // getDigestPreview(undefined, 19) -> 'unknown' + expect(getContainerUpdateBadgeTooltip(undefined)).toBe( + 'Image update available\nCurrent: unknown...\nLatest: unknown...', + ); + }); + + it('renders "unknown" for both digests when neither digest is set', () => { + expect(getContainerUpdateBadgeTooltip(makeStatus({}))).toBe( + 'Image update available\nCurrent: unknown...\nLatest: unknown...', + ); + }); + + it('truncates both digests to 19 chars when both are present', () => { + expect( + getContainerUpdateBadgeTooltip( + makeStatus({ currentDigest: LONG_DIGEST, latestDigest: LONG_DIGEST }), + ), + ).toBe( + 'Image update available\nCurrent: sha256:0123456789ab...\nLatest: sha256:0123456789ab...', + ); + }); + + it('coerces an empty-string digest to "unknown" (|| right arm of getDigestPreview)', () => { + // Empty string is falsy: `(''.slice(0,19)) || 'unknown'` -> 'unknown'. + expect( + getContainerUpdateBadgeTooltip( + makeStatus({ currentDigest: '', latestDigest: '' }), + ), + ).toBe( + 'Image update available\nCurrent: unknown...\nLatest: unknown...', + ); + }); + + it('mixes a present currentDigest with an absent latestDigest', () => { + expect( + getContainerUpdateBadgeTooltip( + makeStatus({ currentDigest: LONG_DIGEST }), + ), + ).toBe( + 'Image update available\nCurrent: sha256:0123456789ab...\nLatest: unknown...', + ); + }); + }); + + describe('getUpdateIconTooltip', () => { + it('returns the short message when updateStatus is undefined (early-return arm)', () => { + expect(getUpdateIconTooltip(undefined)).toBe('Image update available'); + }); + + it('returns the full multi-line message with 12-char previews when updateStatus is present', () => { + expect( + getUpdateIconTooltip( + makeStatus({ currentDigest: LONG_DIGEST, latestDigest: LONG_DIGEST }), + ), + ).toBe( + 'Update available\nCurrent: sha256:01234...\nLatest: sha256:01234...', + ); + }); + + it('returns "unknown" previews when updateStatus is present but digests are absent', () => { + // Exercises the non-undefined updateStatus arm with both digests missing. + expect(getUpdateIconTooltip(makeStatus({}))).toBe( + 'Update available\nCurrent: unknown...\nLatest: unknown...', + ); + }); + }); + + describe('getUpdateButtonClass', () => { + it('returns the amber "confirming" classes', () => { + expect(getUpdateButtonClass('confirming')).toBe( + [ + BASE_CLASS, + 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900', + ].join(' '), + ); + }); + + it('returns the blue cursor-wait "updating" classes', () => { + expect(getUpdateButtonClass('updating')).toBe( + [ + BASE_CLASS, + 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-wait', + ].join(' '), + ); + }); + + it('returns the green "success" classes', () => { + expect(getUpdateButtonClass('success')).toBe( + [BASE_CLASS, 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'].join(' '), + ); + }); + + it('returns the red cursor-help "error" classes', () => { + expect(getUpdateButtonClass('error')).toBe( + [ + BASE_CLASS, + 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 cursor-help', + ].join(' '), + ); + }); + + it('returns the blue cursor-pointer "idle" classes for the default arm', () => { + // The literal 'idle' value falls through to `default`. + expect(getUpdateButtonClass('idle')).toBe( + [ + BASE_CLASS, + 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900', + ].join(' '), + ); + }); + + it('routes any unknown state value to the default arm', () => { + // Cast a bogus value to prove the default branch, not just 'idle'. + const bogus = 'not-a-state' as unknown as UpdateState; + expect(getUpdateButtonClass(bogus)).toBe( + [ + BASE_CLASS, + 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900', + ].join(' '), + ); + }); + }); + + describe('getUpdateButtonLabel', () => { + describe('settingsLoaded = false (early-return arm: always "Update")', () => { + it.each(['idle', 'confirming', 'updating', 'success', 'error'])( + 'returns "Update" for state %s when settings are not loaded', + (state) => { + expect(getUpdateButtonLabel(state, false)).toBe('Update'); + }, + ); + }); + + describe('settingsLoaded = true (switch arms)', () => { + it('returns "Update" for the idle/default arm', () => { + expect(getUpdateButtonLabel('idle', true)).toBe('Update'); + }); + + it('returns "Confirm?" for the confirming arm', () => { + expect(getUpdateButtonLabel('confirming', true)).toBe('Confirm?'); + }); + + it('returns "Updating..." for the updating arm', () => { + expect(getUpdateButtonLabel('updating', true)).toBe('Updating...'); + }); + + it('returns "Queued!" for the success arm', () => { + expect(getUpdateButtonLabel('success', true)).toBe('Queued!'); + }); + + it('returns "Failed" for the error arm', () => { + expect(getUpdateButtonLabel('error', true)).toBe('Failed'); + }); + + it('routes an unknown state value to the default "Update" arm', () => { + const bogus = 'not-a-state' as unknown as UpdateState; + expect(getUpdateButtonLabel(bogus, true)).toBe('Update'); + }); + }); + }); + + describe('getUpdateButtonTooltip', () => { + describe('confirming state', () => { + it('returns the static confirm prompt', () => { + expect(getUpdateButtonTooltip({ state: 'confirming' })).toBe( + 'Click again to confirm update', + ); + }); + }); + + describe('success state', () => { + it('returns the static success message', () => { + expect(getUpdateButtonTooltip({ state: 'success' })).toBe( + '✓ Update completed successfully!', + ); + }); + }); + + describe('updating state', () => { + it('reports 0s elapsed with the default step when storeState is absent (elapsed=0, <=60 arm)', () => { + expect(getUpdateButtonTooltip({ state: 'updating', now: 100000 })).toBe( + 'Processing... (0s)', + ); + }); + + it('computes elapsed seconds from storeState.startedAt and uses storeState.message (<=60 arm)', () => { + // now=100000, startedAt=95000 -> elapsed = round(5000/1000) = 5 + expect( + getUpdateButtonTooltip({ + state: 'updating', + now: 100000, + storeState: { startedAt: 95000, message: 'Pulling layers' }, + }), + ).toBe('Pulling layers (5s)'); + }); + + it('falls back to "Processing..." when storeState is present but has no message', () => { + // step = storeState?.message || 'Processing...' -> right operand. + expect( + getUpdateButtonTooltip({ + state: 'updating', + now: 100000, + storeState: { startedAt: 100000 }, + }), + ).toBe('Processing... (0s)'); + }); + + it('formats as "Xm Ys" once elapsed exceeds 60 seconds (>60 arm)', () => { + // now=1000000, startedAt=0 -> elapsed = 1000 -> floor(1000/60)=16, 1000%60=40 + expect( + getUpdateButtonTooltip({ + state: 'updating', + now: 1000000, + storeState: { startedAt: 0, message: 'Extracting' }, + }), + ).toBe('Extracting (16m 40s)'); + }); + + it('pins the exact 60s boundary as still the seconds format (<=60 arm, not >60)', () => { + // elapsed == 60 is NOT > 60, so the "(60s)" arm must be taken. + expect( + getUpdateButtonTooltip({ + state: 'updating', + now: 60000, + storeState: { startedAt: 0, message: 'Extracting' }, + }), + ).toBe('Extracting (60s)'); + }); + + it('uses Date.now() for `now` when options.now is omitted (?? right arm)', () => { + // Drive the `options.now ?? Date.now()` right arm deterministically by + // stubbing Date.now(); startedAt is anchored to the same stubbed value + // so elapsed is pinned to 7 seconds. + const spy = vi.spyOn(Date, 'now').mockReturnValue(7000); + try { + expect( + getUpdateButtonTooltip({ + state: 'updating', + storeState: { startedAt: 0, message: 'Restarting' }, + }), + ).toBe('Restarting (7s)'); + } finally { + spy.mockRestore(); + } + }); + }); + + describe('error state', () => { + it('prefers storeState.message when present', () => { + expect( + getUpdateButtonTooltip({ + state: 'error', + storeState: { startedAt: 0, message: 'image not found' }, + errorMessage: 'fallback', + }), + ).toBe('✗ Update failed: image not found'); + }); + + it('falls back to errorMessage when storeState is absent', () => { + expect( + getUpdateButtonTooltip({ state: 'error', errorMessage: 'timeout' }), + ).toBe('✗ Update failed: timeout'); + }); + + it('falls back to errorMessage when storeState is present but has no message', () => { + // storeState?.message is undefined -> next operand (errorMessage). + expect( + getUpdateButtonTooltip({ + state: 'error', + storeState: { startedAt: 0 }, + errorMessage: 'denied', + }), + ).toBe('✗ Update failed: denied'); + }); + + it('falls back to "Unknown error" when neither storeState.message nor errorMessage is set', () => { + expect(getUpdateButtonTooltip({ state: 'error' })).toBe( + '✗ Update failed: Unknown error', + ); + }); + }); + + describe('idle/default state', () => { + it('returns "Update container" when updateStatus is absent (early-return arm)', () => { + expect(getUpdateButtonTooltip({ state: 'idle' })).toBe('Update container'); + }); + + it('returns the digest-preview message when updateStatus is present', () => { + expect( + getUpdateButtonTooltip({ + state: 'idle', + updateStatus: makeStatus({ + currentDigest: LONG_DIGEST, + latestDigest: LONG_DIGEST, + }), + }), + ).toBe( + 'Click to update\nCurrent: sha256:01234...\nLatest: sha256:01234...', + ); + }); + + it('returns "unknown" previews when updateStatus is present but digests are absent', () => { + expect( + getUpdateButtonTooltip({ state: 'idle', updateStatus: makeStatus({}) }), + ).toBe('Click to update\nCurrent: unknown...\nLatest: unknown...'); + }); + + it('routes an unknown state value (with no updateStatus) to the default early-return arm', () => { + const bogus = 'not-a-state' as unknown as UpdateState; + expect(getUpdateButtonTooltip({ state: bogus })).toBe('Update container'); + }); + }); + }); +}); diff --git a/frontend-modern/src/features/docker/__tests__/dockerIdentityConflict.branchcov0717.test.ts b/frontend-modern/src/features/docker/__tests__/dockerIdentityConflict.branchcov0717.test.ts new file mode 100644 index 0000000000..4c313e9342 --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/dockerIdentityConflict.branchcov0717.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { collectIdentityConflictHosts } from '../dockerIdentityConflict'; + +// Minimal docker-host Resource factory. Only the fields the function under test +// reads are populated; the partial fixture is cast to Resource (the same pattern +// used by the sibling dockerIdentityConflict.test.tsx). +const host = (overrides: Partial = {}): Resource => + ({ + id: 'docker-host-1', + type: 'docker-host', + name: 'docker-host-1', + ...overrides, + }) as Resource; + +describe('dockerIdentityConflict.branchcov', () => { + describe('collectIdentityConflictHosts — input shape branches', () => { + it('returns an empty array when the input list is empty', () => { + expect(collectIdentityConflictHosts([])).toEqual([]); + }); + + it('returns an empty array when no host has docker.identityConflict (docker absent arm)', () => { + // `host.docker?.identityConflict` short-circuits at `docker` undefined -> + // `!conflict` is true -> continue. + const hosts = [ + host({ id: 'h1', name: 'h1' }), + host({ id: 'h2', name: 'h2' }), + ]; + expect(collectIdentityConflictHosts(hosts)).toEqual([]); + }); + + it('returns an empty array when docker is present but identityConflict is undefined', () => { + // `docker` truthy, `docker.identityConflict` undefined -> !conflict continue. + expect( + collectIdentityConflictHosts([ + host({ id: 'h1', name: 'h1', docker: { runtime: 'docker' } }), + ]), + ).toEqual([]); + }); + }); + + describe('collectIdentityConflictHosts — falsy conflict token (continue arm)', () => { + it('skips a host whose identityConflict is explicitly null', () => { + // null is falsy, so `!conflict` is true and the host is excluded entirely. + expect( + collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { + identityConflict: null as unknown as { hostnames: string[] }, + }, + }), + ]), + ).toEqual([]); + }); + }); + + describe('collectIdentityConflictHosts — hostname normalization branches', () => { + it('handles identityConflict being an empty object via the hostnames ?? [] fallback', () => { + // Truthy `{}` passes the !conflict guard; `conflict.hostnames` is + // undefined -> ?? produces [] -> entry is emitted with an empty list. + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { identityConflict: {} }, + }), + ]); + expect(result).toEqual([{ name: 'h1', hostnames: [] }]); + }); + + it('trims each hostname and preserves non-empty entries verbatim', () => { + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { + identityConflict: { hostnames: [' clone-a ', 'clone-b'] }, + }, + }), + ]); + expect(result).toEqual([ + { name: 'h1', hostnames: ['clone-a', 'clone-b'] }, + ]); + }); + + it('drops hostnames that trim to empty (length === 0 filter arm)', () => { + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { + identityConflict: { hostnames: ['clone-a', ' ', '', '\t'] }, + }, + }), + ]); + expect(result).toEqual([{ name: 'h1', hostnames: ['clone-a'] }]); + }); + + it('returns an empty hostnames list when every hostname is whitespace-only', () => { + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { + identityConflict: { hostnames: [' ', '', ' \t '] }, + }, + }), + ]); + expect(result).toEqual([{ name: 'h1', hostnames: [] }]); + }); + + it('falls back to [] when hostnames is undefined', () => { + // `conflict.hostnames ?? []` right operand when the key is absent. + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { identityConflict: { firstSeen: '2026-07-16T00:00:00Z' } }, + }), + ]); + expect(result).toEqual([{ name: 'h1', hostnames: [] }]); + }); + + it('falls back to [] when hostnames is null (deliberately invalid token)', () => { + // `hostnames` is typed optional string[]; coerce null through `as unknown` + // to exercise the ?? arm against a non-undefined falsy value. + const result = collectIdentityConflictHosts([ + host({ + id: 'h1', + name: 'h1', + docker: { + identityConflict: { + hostnames: null as unknown as string[], + }, + }, + }), + ]); + expect(result).toEqual([{ name: 'h1', hostnames: [] }]); + }); + }); + + describe('collectIdentityConflictHosts — name resolution branches', () => { + it('uses the trimmed resource name when it is non-empty', () => { + expect( + collectIdentityConflictHosts([ + host({ + id: 'fallback-id', + name: ' production-host ', + docker: { identityConflict: { hostnames: ['a'] } }, + }), + ]), + ).toEqual([{ name: 'production-host', hostnames: ['a'] }]); + }); + + it('falls back to host.id when name is whitespace-only', () => { + // `host.name?.trim()` is '' (falsy) -> `|| host.id` arm. + expect( + collectIdentityConflictHosts([ + host({ + id: 'docker-host-42', + name: ' ', + docker: { identityConflict: { hostnames: ['a'] } }, + }), + ]), + ).toEqual([{ name: 'docker-host-42', hostnames: ['a'] }]); + }); + + it('falls back to host.id when name is undefined', () => { + // `host.name?.trim()` is undefined (falsy) -> `|| host.id` arm. + expect( + collectIdentityConflictHosts([ + host({ + id: 'docker-host-7', + name: undefined as unknown as string, + docker: { identityConflict: { hostnames: ['a'] } }, + }), + ]), + ).toEqual([{ name: 'docker-host-7', hostnames: ['a'] }]); + }); + + it('falls back to the literal "host" when both name and id are blank', () => { + // Last `|| 'host'` fallback arm; name '' -> falsy, id '' -> falsy. + expect( + collectIdentityConflictHosts([ + host({ + id: '', + name: '', + docker: { identityConflict: { hostnames: ['a'] } }, + }), + ]), + ).toEqual([{ name: 'host', hostnames: ['a'] }]); + }); + + it('falls back to the literal "host" when name is whitespace and id is empty', () => { + // Combines the trim-to-empty arm for name with the falsy id arm. + expect( + collectIdentityConflictHosts([ + host({ + id: '', + name: ' ', + docker: { identityConflict: { hostnames: ['a'] } }, + }), + ]), + ).toEqual([{ name: 'host', hostnames: ['a'] }]); + }); + }); + + describe('collectIdentityConflictHosts — multi-host collection', () => { + it('collects every conflicting host when 2+ hosts carry identityConflict tokens', () => { + // The function does not cross-compare hosts; it emits one entry per host + // that carries its own identityConflict. Both hosts here are flagged. + const hosts = [ + host({ + id: 'h1', + name: 'host-one', + docker: { + identityConflict: { hostnames: ['clone-a', 'clone-b'] }, + }, + }), + host({ + id: 'h2', + name: 'host-two', + docker: { + identityConflict: { hostnames: ['clone-c'] }, + }, + }), + ]; + expect(collectIdentityConflictHosts(hosts)).toEqual([ + { name: 'host-one', hostnames: ['clone-a', 'clone-b'] }, + { name: 'host-two', hostnames: ['clone-c'] }, + ]); + }); + + it('excludes non-conflicting hosts from a mixed list', () => { + // First and third hosts conflict; the middle host is healthy and must be + // excluded while preserving the order of the conflictors. + const hosts = [ + host({ + id: 'h1', + name: 'conflictor-one', + docker: { + identityConflict: { hostnames: ['dup-a', 'dup-b'] }, + }, + }), + host({ id: 'h2', name: 'healthy-middle' }), + host({ + id: 'h3', + name: 'conflictor-two', + docker: { + identityConflict: { hostnames: ['dup-c'] }, + }, + }), + ]; + expect(collectIdentityConflictHosts(hosts)).toEqual([ + { name: 'conflictor-one', hostnames: ['dup-a', 'dup-b'] }, + { name: 'conflictor-two', hostnames: ['dup-c'] }, + ]); + }); + + it('preserves input order when emitting multiple conflicting hosts', () => { + // The function iterates hosts in order and pushes in order, with no + // deduplication or sorting applied. + const hosts = [ + host({ + id: 'zeta', + name: 'zeta', + docker: { identityConflict: { hostnames: ['z-1'] } }, + }), + host({ + id: 'alpha', + name: 'alpha', + docker: { identityConflict: { hostnames: ['a-1'] } }, + }), + ]; + expect(collectIdentityConflictHosts(hosts).map((h) => h.name)).toEqual([ + 'zeta', + 'alpha', + ]); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/aiProviderPresentation.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/aiProviderPresentation.branchcov0717.test.ts new file mode 100644 index 0000000000..1e4f5fc161 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiProviderPresentation.branchcov0717.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatAIModelRouteLabel, + getAIProviderDisplayName, + getProviderFromModelId, + isPulseOwnedLocalModelRoute, +} from '@/utils/aiProviderPresentation'; + +// `modelIdForLabel`, `explicitModelNameForLabel`, `baseModelLabel`, +// `transportProviderForLabel`, `titleizeRouteNamespace`, `titleizeModelToken`, +// `titleizeModelRouteId`, `gatewayFallbackLabel`, and +// `directProviderRouteFallbackLabel` are module-private (not exported); their +// branches are exercised transitively through the four exported entry points +// below, asserting on their observable outputs. + +describe('getAIProviderDisplayName — branch coverage', () => { + it('returns the curated display name for every known provider key (map hit arm)', () => { + // AI_PROVIDER_DISPLAY_NAMES[provider] arm — sample across simple, multi-word, + // parenthesised, and hyphenated values to lock the map contents. + expect(getAIProviderDisplayName('anthropic')).toBe('Anthropic'); + expect(getAIProviderDisplayName('openai')).toBe('OpenAI'); + expect(getAIProviderDisplayName('gemini')).toBe('Google Gemini'); + expect(getAIProviderDisplayName('codex-subscription')).toBe('Codex subscription (local)'); + expect(getAIProviderDisplayName('claude-subscription')).toBe('Claude subscription (local)'); + expect(getAIProviderDisplayName('pulse')).toBe('Pulse'); + expect(getAIProviderDisplayName('together')).toBe('Together AI'); + }); + + it('falls back to the raw provider string for an unknown key (|| arm)', () => { + // AI_PROVIDER_DISPLAY_NAMES[provider] === undefined -> || provider fallback. + expect(getAIProviderDisplayName('not-a-real-provider')).toBe('not-a-real-provider'); + }); + + it('returns the empty string for an empty-string key (both arms collapse to "")', () => { + // map miss -> '' || '' -> ''. Documents the boundary behaviour. + expect(getAIProviderDisplayName('')).toBe(''); + }); +}); + +describe('getProviderFromModelId — branch coverage', () => { + it('returns the substring before the first colon when colonIndex > 0 (colon branch)', () => { + expect(getProviderFromModelId('anthropic:claude-3-opus')).toBe('anthropic'); + expect(getProviderFromModelId('openrouter:anthropic/claude')).toBe('openrouter'); + expect(getProviderFromModelId('pulse:local-inventory')).toBe('pulse'); + }); + + it('skips the colon branch when the colon is at index 0 (colonIndex === 0 boundary)', () => { + // ':foo' -> colonIndex 0 -> 0 > 0 false -> falls through to keyword detection + // -> no keyword match -> default 'ollama'. + expect(getProviderFromModelId(':something')).toBe('ollama'); + }); + + it('maps each openrouter regex prefix variant to "openrouter"', () => { + // Exercises every OR clause of + // /^(openai|anthropic|google|deepseek|meta-llama|mistralai|x-ai|xai|cohere|qwen)\// + expect(getProviderFromModelId('openai/gpt-4o')).toBe('openrouter'); + expect(getProviderFromModelId('anthropic/claude-3')).toBe('openrouter'); + expect(getProviderFromModelId('google/gemini-pro')).toBe('openrouter'); + expect(getProviderFromModelId('deepseek/deepseek-chat')).toBe('openrouter'); + expect(getProviderFromModelId('meta-llama/llama-3')).toBe('openrouter'); + expect(getProviderFromModelId('mistralai/mistral-large')).toBe('openrouter'); + expect(getProviderFromModelId('x-ai/grok')).toBe('openrouter'); + expect(getProviderFromModelId('xai/grok-2')).toBe('openrouter'); + expect(getProviderFromModelId('cohere/command-r')).toBe('openrouter'); + expect(getProviderFromModelId('qwen/qwen-2.5')).toBe('openrouter'); + }); + + it('maps each anthropic keyword (claude/opus/sonnet/haiku) to "anthropic"', () => { + expect(getProviderFromModelId('claude-3-opus')).toBe('anthropic'); + expect(getProviderFromModelId('my-opus-model')).toBe('anthropic'); + expect(getProviderFromModelId('sonnet-4')).toBe('anthropic'); + expect(getProviderFromModelId('haiku-turbo')).toBe('anthropic'); + }); + + it('maps each openai keyword (gpt/o1/o3/o4) to "openai"', () => { + expect(getProviderFromModelId('gpt-4o-mini')).toBe('openai'); + expect(getProviderFromModelId('o1-preview')).toBe('openai'); + expect(getProviderFromModelId('o3-mini')).toBe('openai'); + expect(getProviderFromModelId('o4-analysis')).toBe('openai'); + }); + + it('maps a deepseek-keyword id to "deepseek"', () => { + expect(getProviderFromModelId('deepseek-v3')).toBe('deepseek'); + }); + + it('maps a gemini-keyword id to "gemini"', () => { + expect(getProviderFromModelId('gemini-1.5-pro')).toBe('gemini'); + }); + + it('maps a glm-keyword id to "zai"', () => { + expect(getProviderFromModelId('glm-4.5')).toBe('zai'); + }); + + it('maps a groq-keyword id to "groq"', () => { + expect(getProviderFromModelId('groq-llama-3')).toBe('groq'); + }); + + it('maps a mistral-keyword id to "mistral"', () => { + expect(getProviderFromModelId('mistral-7b')).toBe('mistral'); + }); + + it('maps a cerebras-keyword id to "cerebras"', () => { + expect(getProviderFromModelId('cerebras-llama3')).toBe('cerebras'); + }); + + it('falls back to "ollama" when no prefix/keyword matches (default arm)', () => { + // Includes the empty-string boundary, which has no colon and no keyword. + expect(getProviderFromModelId('random-model')).toBe('ollama'); + expect(getProviderFromModelId('')).toBe('ollama'); + }); +}); + +describe('isPulseOwnedLocalModelRoute — branch coverage', () => { + it('returns true for the canonical pulse:local-inventory route', () => { + expect(isPulseOwnedLocalModelRoute('pulse:local-inventory')).toBe(true); + }); + + it('returns true for the canonical pulse:mock-assistant route', () => { + expect(isPulseOwnedLocalModelRoute('pulse:mock-assistant')).toBe(true); + }); + + it('honours the .trim() normaliser for whitespace-padded routes', () => { + // Boolean(LOCAL_MODEL_ROUTE_LABELS[modelId.trim()]) — verifies trim arm. + expect(isPulseOwnedLocalModelRoute(' pulse:local-inventory ')).toBe(true); + }); + + it('returns false for an unknown pulse-namespaced route (Boolean(undefined) arm)', () => { + expect(isPulseOwnedLocalModelRoute('pulse:unknown')).toBe(false); + }); + + it('returns false for a generic non-pulse id', () => { + expect(isPulseOwnedLocalModelRoute('anthropic:claude-3')).toBe(false); + }); + + it('returns false for the empty string (misses the map)', () => { + expect(isPulseOwnedLocalModelRoute('')).toBe(false); + }); +}); + +describe('formatAIModelRouteLabel — branch coverage', () => { + describe('string input — direct (non-gateway) path', () => { + it('builds "Provider: Model" for a colon-namespaced direct-route id', () => { + // transportProvider -> getProviderFromModelId('anthropic:...') colon arm. + // directProviderRouteFallbackLabel returns the formatted pair. + expect(formatAIModelRouteLabel('anthropic:claude-3-opus')).toBe('Anthropic: Claude 3 Opus'); + }); + + it('returns the raw id when there is no separator and no keyword match', () => { + // directProviderRouteFallbackLabel returns '' (separator <= 0) -> baseModelLabel + // -> id.split(':').pop() || id falls back to the whole id. + expect(formatAIModelRouteLabel('random-model')).toBe('random-model'); + }); + + it('returns the curated Pulse label for a pulse-owned local route', () => { + // directProviderRouteFallbackLabel early-returns '' for pulse-owned routes + // -> baseModelLabel returns the LOCAL_MODEL_ROUTE_LABELS entry verbatim. + expect(formatAIModelRouteLabel('pulse:local-inventory')).toBe('Pulse inventory'); + expect(formatAIModelRouteLabel('pulse:mock-assistant')).toBe('Pulse mock Assistant'); + }); + + it('falls back to the full id when the separator sits at the end of the id', () => { + // directProviderRouteFallbackLabel: separator === id.length - 1 -> return ''. + // baseModelLabel: id.split(':').pop() === '' -> || id returns the whole id. + expect(formatAIModelRouteLabel('mistral:')).toBe('mistral:'); + }); + + it('returns the empty string for an empty-string model', () => { + // transportProviderForLabel: id ? getProviderFromModelId(id) : '' -> ''. + // !provider short-circuits the main guard, label resolves to ''. + expect(formatAIModelRouteLabel('')).toBe(''); + }); + }); + + describe('string input — gateway (openrouter) path', () => { + it('builds "Upstream: Model via OpenRouter" for a slash-separated gateway payload', () => { + // gatewayFallbackLabel: payload has slash at index > 0, both halves non-empty. + // label does not already contain "OpenRouter" -> via suffix appended. + expect(formatAIModelRouteLabel('openrouter:anthropic/claude-3-opus')).toBe( + 'Anthropic: Claude 3 Opus via OpenRouter', + ); + }); + + it('titleizes known upstream namespaces (meta-llama) and 8b-style model tokens', () => { + // Also exercises titleizeModelToken /^[0-9]+b$/ -> '8B' arm. + expect(formatAIModelRouteLabel('openrouter:meta-llama/llama-3-8b')).toBe( + 'Meta Llama: Llama 3 8B via OpenRouter', + ); + }); + + it('titleizes an unknown upstream namespace via the split/title-case fallback', () => { + // titleizeRouteNamespace: not in UPSTREAM_PROVIDER_DISPLAY_NAMES -> split/-> + // map(charAt(0).upper + slice) fallback. + expect(formatAIModelRouteLabel('openrouter:custom-ns/some-model')).toBe( + 'Custom Ns: Some Model via OpenRouter', + ); + }); + + it('falls back to baseModelLabel and appends via-suffix when payload has no slash', () => { + // gatewayFallbackLabel: slashIndex <= 0 -> return ''. + expect(formatAIModelRouteLabel('openrouter:gpt-4o')).toBe('gpt-4o via OpenRouter'); + }); + + it('treats slashIndex === 0 (slash at payload start) as no usable namespace', () => { + // gatewayFallbackLabel: slashIndex <= 0 boundary at exactly 0. + expect(formatAIModelRouteLabel('openrouter:/foo')).toBe('/foo via OpenRouter'); + }); + + it('treats slashIndex === payload.length - 1 (slash at payload end) as no usable model', () => { + // gatewayFallbackLabel: trailing-slash boundary. + expect(formatAIModelRouteLabel('openrouter:foo/')).toBe('foo/ via OpenRouter'); + }); + + it('suppresses the via-suffix when label already contains the provider name', () => { + // label.toLowerCase().includes(providerName.toLowerCase()) true arm. + expect(formatAIModelRouteLabel('openrouter:openrouter/auto')).toBe('OpenRouter: Auto'); + }); + + it('returns the bare payload when the upstream namespace normalises to empty', () => { + // titleizeRouteNamespace('~/foo' slice 0..slashIndex = '~') -> normalised '' + // -> !upstreamProvider -> gatewayFallbackLabel returns '' -> baseModelLabel. + expect(formatAIModelRouteLabel('openrouter:~/foo')).toBe('~/foo via OpenRouter'); + }); + }); + + describe('object input — transportProviderForLabel branches', () => { + it('honours an explicit provider override that is a gateway provider', () => { + // transportProviderForLabel: model.provider?.trim() truthy -> return provider. + // Gateway path taken from the override alone (no colon/slash in id). + expect(formatAIModelRouteLabel({ id: 'foo', provider: 'openrouter' })).toBe( + 'foo via OpenRouter', + ); + }); + + it('honours an explicit provider override that is a non-gateway provider', () => { + // Same transportProvider arm, but direct path. id has no separator so + // directProviderRouteFallbackLabel returns '' -> baseModelLabel returns id. + expect(formatAIModelRouteLabel({ id: 'foo', provider: 'mistral' })).toBe('foo'); + }); + + it('early-returns "" inside directProviderRouteFallbackLabel when the slice-provider is a gateway provider', () => { + // Override is non-gateway (mistral) -> direct path. id 'openrouter:foo' + // slices to 'openrouter' which IS in GATEWAY_MODEL_PROVIDERS -> return ''. + expect(formatAIModelRouteLabel({ id: 'openrouter:foo', provider: 'mistral' })).toBe('foo'); + }); + + it('derives the provider from the id when no override is supplied', () => { + // transportProviderForLabel: model.provider undefined -> falls to + // getProviderFromModelId(id) -> 'anthropic' via the colon branch. + expect(formatAIModelRouteLabel({ id: 'anthropic:claude-3-opus' })).toBe( + 'Anthropic: Claude 3 Opus', + ); + }); + + it('returns "" when the object has neither provider nor a non-empty id', () => { + // transportProviderForLabel: id ? getProviderFromModelId(id) : '' -> ''. + expect(formatAIModelRouteLabel({ id: '' })).toBe(''); + }); + }); + + describe('object input — explicitModelNameForLabel branches', () => { + it('uses the explicit name when it differs from id and routePayload', () => { + // explicitModelNameForLabel returns the trimmed name -> baseModelLabel uses it. + expect( + formatAIModelRouteLabel({ id: 'anthropic:claude-3-opus', name: 'My Custom Claude' }), + ).toBe('My Custom Claude'); + }); + + it('drops the explicit name when it equals the whole id (name === id arm)', () => { + // explicitModelNameForLabel: name === id -> return ''. baseModelLabel falls + // through to id.split(':').pop(). + expect(formatAIModelRouteLabel({ id: 'foo', name: 'foo' })).toBe('foo'); + }); + + it('drops the explicit name when it equals the route payload (name === routePayload arm)', () => { + // explicitModelNameForLabel: routePayload === name -> return ''. Falls back + // through directProviderRouteFallbackLabel. + expect(formatAIModelRouteLabel({ id: 'anthropic:claude', name: 'claude' })).toBe( + 'Anthropic: Claude', + ); + }); + + it('treats a whitespace-only name as absent (name?.trim() falsy arm)', () => { + // explicitModelNameForLabel: ' '.trim() === '' -> !name -> return ''. + expect(formatAIModelRouteLabel({ id: 'foo', name: ' ' })).toBe('foo'); + }); + }); +});