ADR-013 Phase 4: relocate 4 self-contained plugin frontends

Relocate applications, geenforce, knowledgebase, and machines - each owns only
its own views dir, so a clean move to plugins/<name>/frontend/ (views/ +
routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its
vitest spec move with it (ManifestEditor imports it as a sibling).

Machinery fixes this batch surfaced:
- routes.gen.js codegen uses namespace imports (import * as p_x). A route file
  without a `toplevel` export is undefined on the namespace instead of a strict-
  ESM missing-binding build error.
- vitest gains a `pretest` stage so plugin-frontend specs (now under
  plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/.

Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper)
renders fully from its staged frontend. Build + 58 vitest + naming green.
This commit is contained in:
cproudlock
2026-07-18 23:51:23 -04:00
parent af9a3b190b
commit 23dc9fa379
21 changed files with 52 additions and 49 deletions

View File

@@ -0,0 +1,30 @@
/**
* GE-Enforce plugin routes.
*
* A top-level section (not under /settings) - the manifest editor + fleet
* reports are a large operational surface, so they get their own full-width
* shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the
* section when the plugin is disabled. Admin-only.
*/
export default [
{
path: 'geenforce',
component: () => import('./views/GeEnforceLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' },
children: [
{ path: '', redirect: '/geenforce/manifests' },
{
path: 'manifests',
name: 'geenforce-manifests',
component: () => import('./views/ManifestEditor.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
},
{
path: 'reports',
name: 'geenforce-reports',
component: () => import('./views/EnforcementReports.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]
}
]

View File

@@ -0,0 +1,161 @@
<template>
<div class="enforcement-reports">
<div class="page-header">
<h2>Enforcement Reports</h2>
</div>
<p class="setting-description">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures.
</p>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="filters">
<input
type="text"
class="form-control"
v-model="filterHost"
placeholder="Filter hostname"
@keyup.enter="load"
/>
<input
type="text"
class="form-control"
v-model="filterScope"
placeholder="Filter PC type"
@keyup.enter="load"
/>
<button class="btn btn-primary" @click="load">Filter</button>
<button class="btn btn-secondary" @click="clearFilters">Clear</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
<th>Last check-in</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.reportid">
<td>{{ report.hostname }}</td>
<td>{{ report.scopename }}</td>
<td>
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
{{ report.receivedlatest ? 'yes' : 'behind' }}
</span>
</td>
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
<td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
</td>
</tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Detail modal -->
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal modal-report">
<div class="modal-header">
<h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
<button class="modal-close" @click="detail = null">x</button>
</div>
<div class="modal-body">
<p class="setting-description">
Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
status {{ detail.status }}
</p>
<div class="table-container">
<table>
<thead>
<tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr>
</thead>
<tbody>
<tr v-for="(result, index) in detail.results" :key="index">
<td>{{ result.entryname }}</td>
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
<td class="muted">{{ result.exitcode ?? '' }}</td>
<td class="muted">{{ result.message }}</td>
</tr>
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="detail = null">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '@/api'
const reports = ref([])
const detail = ref(null)
const error = ref('')
const filterHost = ref('')
const filterScope = ref('')
function payload(response) { return response.data.data }
async function load() {
const params = {}
if (filterHost.value) params.hostname = filterHost.value
if (filterScope.value) params.scopename = filterScope.value
try {
reports.value = payload(await api.get('/geenforce/reports', { params }))
error.value = ''
} catch (e) { error.value = 'Failed to load reports' }
}
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
async function openDetail(reportid) {
try {
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
} catch (e) { error.value = 'Failed to load report detail' }
}
function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
}
function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || ''
}
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
load()
</script>
<style scoped>
.enforcement-reports { max-width: 1100px; }
.muted { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.modal-report { max-width: 640px; }
.modal-close {
background: transparent;
border: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="geenforce-section">
<div class="section-header">
<h1>GE-Enforce</h1>
<p class="section-sub">Desired-state install manifests for imaging PC types, and the fleet's reported results.</p>
</div>
<nav class="section-tabs">
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
</nav>
<router-view />
</div>
</template>
<script setup>
// Tabbed shell for the GE-Enforce section. Children render the manifest editor
// and the fleet-compliance reports full-width (not squeezed into the settings rail).
</script>
<style scoped>
.geenforce-section { max-width: 1400px; }
.section-header { margin-bottom: 0.5rem; }
.section-header h1 { margin: 0; }
.section-sub { color: var(--text-light); margin: 0.25rem 0 0; }
.section-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin: 1rem 0 1.25rem;
}
.tab {
padding: 0.5rem 0.9rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover { color: var(--text); }
.tab.router-link-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
// Pure, framework-free helpers for the GE-Enforce manifest editor.
//
// ManifestEditor.vue imports these directly (the component no longer keeps its
// own copies), so the unit tests in entryForm.spec.js exercise the shipped
// code path. Change the editor logic HERE.
//
// Everything here is a plain function of its inputs. No Vue, no reactivity,
// no network. That is the whole point - deterministic logic we can pin down.
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
// Plain-language description of each detection method, shown under the Detection
// method dropdown so a first-time site admin understands what "present" means
// for the method they picked. Key '' is the no-detection case.
export const DETECTION_METHOD_HINTS = {
'': 'No detection rule: the action runs every cycle.',
Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).',
File: 'Already correct if the file at Detection path exists.',
FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.',
Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.',
MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.',
ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.',
pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.',
Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.',
}
// Description for the currently selected detection method, or '' if unknown.
export function detectionMethodHint(method) {
return DETECTION_METHOD_HINTS[method || ''] || ''
}
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
export function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
inuseBehavior: '', inuseProcesses: [], appid: null,
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
// Split a comma list into trimmed non-empty parts.
export function splitList(value) {
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
}
// Turn an edit-form object into the API body sent to create/update an entry.
//
// Rules that matter (and that the specs pin):
// - appid is always present (null unlinks). It is shopdb metadata; the backend
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
// - empty/undefined/null scalars are dropped.
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
// - comma lists become arrays, and are dropped when empty.
// - preinstall flags only appear when truthy.
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
export function buildEntryPayload(form) {
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
// off the manifest JSON.
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
}
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
}
const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (form[flag]) out[flag] = true
}
if (form.inuseBehavior) {
out.InUseCheck = {
Behavior: form.inuseBehavior,
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const processData = { Name: process.name }
if (process.exepath) processData.ExePath = process.exepath
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
processData.GracefulCloseTimeoutSec = Number(process.timeout)
}
return processData
}),
}
}
return out
}
// Which entry types the type dropdown offers, given the scope phase. Preinstall
// supports MSI/EXE only, but keeps a current out-of-set value visible.
export function availableEntryTypes(isPreinstall, currentType) {
if (!isPreinstall) return ENTRY_TYPES
const allowed = ['MSI', 'EXE']
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
}
// Which detection methods the dropdown offers. Preinstall supports Registry/File
// only, but keeps a current out-of-set value visible.
export function availableDetectionMethods(isPreinstall, currentMethod) {
if (!isPreinstall) return DETECTION_METHODS
const allowed = ['Registry', 'File']
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
}
// Which targeting gates a scope actually surfaces by default (before "Show all").
export function targetingGates(scope) {
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
}
// The contextual hint under the Targeting header.
export function targetingHint(scope) {
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
}
// One-line summary of what a scope installs (installer entries only).
export function scopeSummary(scope) {
if (!scope || !scope.entries || !scope.entries.length) return ''
const installers = scope.entries
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
.map(e => e.Name)
const shown = installers.slice(0, 6).join(', ')
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
return `${apps}${scope.entries.length} entries; runs after common.`
}
// Verb table for describeEntry.
export const ACTION_VERB = {
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
PS1: 'Runs a script for', INF: 'Installs a driver for',
File: 'Copies a file for', Registry: 'Sets a registry value for',
}
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
export function describeEntry(entry) {
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
const method = entry.DetectionMethod
if (!method || method === 'Always') text += '; runs every cycle'
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
else if (method === 'Hash') text += '; re-copies if the file changed'
else if (method === 'MarkerFile') text += '; installs once'
else text += '; reinstalls if not detected'
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
text += '; specific hostname(s)'
} else if (entry.PCTypes && entry.PCTypes.length) {
text += `; ${entry.PCTypes.length} PC type(s)`
}
if (entry.appname) text += `; tracked: ${entry.appname}`
return text
}

View File

@@ -0,0 +1,321 @@
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('')
})
})