import { describe, it, expect } from 'vitest' import { blankEntry, splitList, buildEntryPayload, availableEntryTypes, availableDetectionMethods, targetingGates, targetingHint, scopeSummary, describeEntry, detectionMethodHint, ENTRY_TYPES, DETECTION_METHODS, } from './entryForm.js' // These specs pin the deterministic logic that turns the manifest editor form // into an API body, and the various display helpers. This is the code most // likely to silently regress (numeric coercion, dropped-vs-kept fields, the // appid-must-never-be-a-manifest-scalar rule). describe('splitList', () => { it('trims, drops empties, and returns an array', () => { expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c']) }) it('returns an empty array for null/empty', () => { expect(splitList('')).toEqual([]) expect(splitList(null)).toEqual([]) expect(splitList(undefined)).toEqual([]) }) }) describe('buildEntryPayload - appid handling', () => { it('always emits appid as a top-level key, defaulting to null', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI' }) expect(out.appid).toBeNull() expect('appid' in out).toBe(true) }) it('passes a linked appid through unchanged', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 }) expect(out.appid).toBe(41) }) it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => { // appid lives at the top level as metadata; it must not appear nested in // any manifest sub-structure. Guard against a future refactor smuggling it // into e.g. InUseCheck or a detection block. const out = buildEntryPayload({ Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1', DetectionMethod: 'Registry', inuseBehavior: 'ForceClose', inuseProcesses: [{ name: 'p', exepath: '', timeout: null }], }) // Only the top-level appid key carries it. const serialized = JSON.stringify({ ...out, appid: undefined }) expect(serialized.includes('appid')).toBe(false) expect(serialized.toLowerCase().includes('"appid"')).toBe(false) }) }) describe('buildEntryPayload - scalar filtering', () => { it('drops empty-string, null, and undefined scalars', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null, Script: undefined, LogFile: 'setup.log', }) expect('Installer' in out).toBe(false) expect('InstallArgs' in out).toBe(false) expect('Script' in out).toBe(false) expect(out.LogFile).toBe('setup.log') }) it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note', }) expect(out._CmmVersion).toBe('2019') expect(out._comment).toBe('note') }) it('only emits DetectionMethod when set', () => { expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false) expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion') }) it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => { expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false) expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300) }) }) describe('buildEntryPayload - RegValue coercion', () => { it('coerces DWord to a Number', () => { const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' }) expect(out.RegValue).toBe(1) expect(typeof out.RegValue).toBe('number') }) it('coerces QWord to a Number', () => { const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' }) expect(out.RegValue).toBe(42) }) it('leaves String reg values as strings', () => { const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' }) expect(out.RegValue).toBe('hello') expect(typeof out.RegValue).toBe('string') }) it('does not emit RegValue for a non-Registry type', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' }) expect('RegValue' in out).toBe(false) }) it('drops an empty RegValue even for Registry', () => { const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' }) expect('RegValue' in out).toBe(false) }) }) describe('buildEntryPayload - comma lists to arrays', () => { it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections', TargetHostnames: 'host-a, host-b', TargetMachineNumbers: '0101, 0102', }) expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections']) expect(out.TargetHostnames).toEqual(['host-a', 'host-b']) expect(out.TargetMachineNumbers).toEqual(['0101', '0102']) }) it('omits the array keys when the list is empty', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' }) expect('PCTypes' in out).toBe(false) expect('TargetHostnames' in out).toBe(false) }) }) describe('buildEntryPayload - preinstall flags', () => { it('emits only the truthy flags', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true, }) expect(out.PreEnrollment).toBe(true) expect(out.PCTypesStrict).toBe(true) expect('KillAfterDetection' in out).toBe(false) }) }) describe('buildEntryPayload - InUseCheck processes', () => { it('carries Name, optional ExePath, and a numeric timeout per process', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen', inuseProcesses: [ { name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 }, { name: 'nodmis', exepath: '', timeout: null }, ], }) expect(out.InUseCheck.Behavior).toBe('CloseAndReopen') expect(out.InUseCheck.Processes).toEqual([ { Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 }, { Name: 'nodmis' }, ]) }) it('drops processes with no name', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose', inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }], }) expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }]) }) it('coerces a string timeout to a number', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: 'Defer', inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }], }) expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15) }) it('emits no InUseCheck when there is no behavior', () => { const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' }) expect('InUseCheck' in out).toBe(false) }) }) describe('buildEntryPayload - a blank new entry round-trips', () => { it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => { const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' }) // Type MSI, appid null. RegType is default String but non-Registry, so it // is still carried as a scalar (it is in the scalar list). Confirm the shape. expect(out.Name).toBe('Fresh') expect(out.Type).toBe('MSI') expect(out.appid).toBeNull() expect('InUseCheck' in out).toBe(false) expect('PCTypes' in out).toBe(false) }) }) describe('availableEntryTypes', () => { it('returns the full list for a non-preinstall scope', () => { expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES) }) it('restricts to MSI/EXE for preinstall', () => { expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE']) }) it('keeps an out-of-set current value visible in preinstall', () => { expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry']) }) }) describe('availableDetectionMethods', () => { it('returns the full list for a non-preinstall scope', () => { expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS) }) it('restricts to Registry/File for preinstall', () => { expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File']) }) it('keeps an out-of-set current value visible in preinstall', () => { expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion']) }) }) describe('targetingGates', () => { it('defaults to pctypes-only when there is no scope', () => { expect(targetingGates(null)).toEqual({ pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false, }) }) it('shows pctypes for a fleetwide/common/preinstall scope', () => { expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true) expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true) expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true) }) it('shows the cmm gate for a cmm-named scope', () => { expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true) }) it('does not name-match machinenumbers (data-driven only)', () => { // 'nocollections' must not trip a loose name match; machinenumbers is purely // data-driven off the entries. const gates = targetingGates({ scopename: 'nocollections', entries: [] }) expect(gates.machinenumbers).toBe(false) }) it('surfaces gates from entry data', () => { const gates = targetingGates({ scopename: 'per-type', entries: [ { PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] }, ], }) expect(gates.pctypes).toBe(true) expect(gates.machinenumbers).toBe(true) expect(gates.hostnames).toBe(true) }) }) describe('targetingHint', () => { it('is empty with no scope', () => { expect(targetingHint(null)).toBe('') }) it('describes a fleet-wide common manifest', () => { expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/) }) it('describes a preinstall manifest', () => { expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/) }) it('names a per-type scope', () => { expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/) }) }) describe('scopeSummary', () => { it('is empty with no entries', () => { expect(scopeSummary({ entries: [] })).toBe('') expect(scopeSummary(null)).toBe('') }) it('lists installer names and the entry count', () => { const summary = scopeSummary({ entries: [ { Type: 'MSI', Name: 'eDNC' }, { Type: 'Registry', Name: 'reg' }, ], }) expect(summary).toBe('Installs eDNC. 2 entries; runs after common.') }) it('truncates past six installers with a +N more', () => { const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` })) const summary = scopeSummary({ entries }) expect(summary).toMatch(/\+2 more/) }) }) describe('describeEntry', () => { it('describes an MSI with FileVersion detection using the expected version', () => { const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' }) expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1') }) it('says runs every cycle for Always / no detection', () => { expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle') expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle') }) it('appends a CMM gate note', () => { const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' }) expect(text).toMatch(/CMM 2016 bays only/) }) it('appends the tracked app name when present', () => { const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' }) expect(text).toMatch(/tracked: PC-DMIS/) }) it('falls back to Applies for an unknown type', () => { expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/) }) }) describe('detectionMethodHint', () => { it('has a non-empty hint for every detection method', () => { for (const method of DETECTION_METHODS) { expect(detectionMethodHint(method).length).toBeGreaterThan(0) } }) it('describes the no-detection case for empty/undefined', () => { expect(detectionMethodHint('')).toMatch(/every cycle/) expect(detectionMethodHint(undefined)).toMatch(/every cycle/) }) it('ties FileVersion to the compliance panel', () => { expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/) }) it('returns empty string for an unknown method', () => { expect(detectionMethodHint('Nonsense')).toBe('') }) })