import { describe, it, expect } from 'vitest'; import { buildFleetDossier, type FleetDossierInput, type FleetDossierStack } from './fleetDossier'; import { EMPTY_DOSSIER_FIELDS, type StackDossierFields } from './dossierMarkdown'; import type { AnatomyMarkdownInput } from './anatomyMarkdown'; const fields = (over: Partial = {}): StackDossierFields => ({ ...EMPTY_DOSSIER_FIELDS, ...over }); const plexAnatomy: AnatomyMarkdownInput = { stackName: 'plex', services: ['plex'], ports: { plex: [{ host: '32400', container: '32400', proto: 'tcp' }] }, volumes: { plex: [{ host: './config', container: '/config' }] }, restart: 'unless-stopped', envFile: '.env', envVarCount: 3, missingVars: ['CLAIM_TOKEN'], networkName: 'media', gitSource: null, }; const input = (): FleetDossierInput => ({ generatedAt: '2026-06-07T00:00:00.000Z', senchoVersion: '0.90.0', nodes: [ { id: 1, name: 'local', type: 'local', reachable: true, stacks: [ { stackName: 'plex', anatomy: plexAnatomy, dossier: fields({ purpose: 'Media server', access_urls: 'https://plex.example', static_ip: '10.0.10.4', vlan: '10', firewall_notes: '32400 open on LAN', }), }, { stackName: 'broken', anatomy: null, dossier: fields({ purpose: 'unparseable but documented' }) }, ], }, { id: 2, name: 'media-node', type: 'remote', reachable: false, skipReason: 'node offline' }, ], }); describe('buildFleetDossier', () => { it('emits index, network, a node page per node, and a stack page per reachable stack', () => { const files = buildFleetDossier(input()); expect(Object.keys(files).sort()).toEqual([ 'index.md', 'network.md', 'nodes/local.md', 'nodes/media-node.md', 'stacks/local--broken.md', 'stacks/local--plex.md', ]); }); it('lists nodes and an explicit skipped section with reasons in index.md', () => { const md = buildFleetDossier(input())['index.md']; expect(md).toContain('# Homelab Dossier'); expect(md).toContain('_Generated 2026-06-07T00:00:00.000Z ยท Sencho 0.90.0_'); expect(md).toContain('| [local](nodes/local.md) | local | reachable | 2 |'); expect(md).toContain('| [media-node](nodes/media-node.md) | remote | unreachable | - |'); expect(md).toContain('## Skipped nodes'); expect(md).toContain('- **media-node** (remote): node offline'); }); it('omits the skipped section when every node is reachable', () => { const data = input(); data.nodes = [data.nodes[0]]; expect(buildFleetDossier(data)['index.md']).not.toContain('## Skipped nodes'); }); it('reuses the stack dossier generator for a parseable stack', () => { const md = buildFleetDossier(input())['stacks/local--plex.md']; expect(md).toContain('# plex'); expect(md).toContain('| plex | 32400 | 32400 | tcp |'); expect(md).toContain('## Operator notes'); expect(md).toContain('- **Purpose:** Media server'); }); it('emits a stub page with operator notes when compose cannot be parsed', () => { const md = buildFleetDossier(input())['stacks/local--broken.md']; expect(md).toContain('# broken'); expect(md).toContain('compose.yaml could not be parsed'); expect(md).toContain('- **Purpose:** unparseable but documented'); }); it('aggregates port, volume, network, env, access-URL, and infra maps in network.md', () => { const md = buildFleetDossier(input())['network.md']; expect(md).toContain('## Port map'); expect(md).toContain('| local | plex | plex | 32400 | 32400 | tcp |'); expect(md).toContain('## Volume map'); expect(md).toContain('| local | plex | plex | ./config | /config |'); expect(md).toContain('## Network map'); expect(md).toContain('| local | plex | media |'); expect(md).toContain('## Environment checklist'); expect(md).toContain('| local | plex | .env | 3 | CLAIM_TOKEN |'); expect(md).toContain('## Access URLs'); expect(md).toContain('| local | plex | https://plex.example |'); expect(md).toContain('## VLAN / static IP / firewall'); expect(md).toContain('| local | plex | 10.0.10.4 | 10 | 32400 open on LAN |'); }); it('renders _none_ for empty map sections', () => { const md = buildFleetDossier({ generatedAt: 't', senchoVersion: 'v', nodes: [{ id: 1, name: 'n', type: 'local', reachable: true, stacks: [] }], })['network.md']; expect(md).toContain('## Port map\n\n_none_'); }); it('disambiguates colliding node slugs with the node id', () => { const files = buildFleetDossier({ generatedAt: 't', senchoVersion: 'v', nodes: [ { id: 1, name: 'Media Node', type: 'local', reachable: true, stacks: [] }, { id: 2, name: 'media node', type: 'remote', reachable: false, skipReason: 'x' }, ], }); expect(files['nodes/media-node.md']).toBeDefined(); expect(files['nodes/media-node-2.md']).toBeDefined(); }); it('aggregates rows from every reachable node and stack in network.md', () => { const md = buildFleetDossier({ generatedAt: 't', senchoVersion: 'v', nodes: [ { id: 1, name: 'alpha', type: 'local', reachable: true, stacks: [{ stackName: 'plex', anatomy: plexAnatomy, dossier: fields() }], }, { id: 2, name: 'beta', type: 'remote', reachable: true, stacks: [{ stackName: 'grafana', anatomy: { ...plexAnatomy, stackName: 'grafana', ports: { grafana: [{ host: '3000', container: '3000', proto: 'tcp' }] }, volumes: {}, networkName: 'grafana_net' }, dossier: fields(), }], }, { id: 3, name: 'gamma', type: 'remote', reachable: false, skipReason: 'node offline' }, ], })['network.md']; // One port row from each reachable node; the offline node contributes nothing. expect(md).toContain('| alpha | plex | plex | 32400 | 32400 | tcp |'); expect(md).toContain('| beta | grafana | grafana | 3000 | 3000 | tcp |'); expect(md).not.toContain('gamma'); }); it('escapes pipe characters in node and stack names so tables stay intact', () => { const files = buildFleetDossier({ generatedAt: 't', senchoVersion: 'v', nodes: [{ id: 1, name: 'node|x', type: 'local', reachable: true, stacks: [{ stackName: 'app', anatomy: { ...plexAnatomy, stackName: 'app' }, dossier: fields() }], }], }); expect(files['index.md']).toContain('node\\|x'); expect(files['network.md']).toContain('| node\\|x | app |'); }); it('disambiguates stack names on one node that slugify to the same value', () => { const mk = (name: string): FleetDossierStack => ({ stackName: name, anatomy: { ...plexAnatomy, stackName: name }, dossier: fields() }); const files = buildFleetDossier({ generatedAt: 't', senchoVersion: 'v', nodes: [{ id: 1, name: 'local', type: 'local', reachable: true, stacks: [mk('Web'), mk('web')] }], }); // Both stacks keep their own page instead of one overwriting the other... expect(files['stacks/local--web.md']).toBeDefined(); expect(files['stacks/local--web-2.md']).toBeDefined(); expect(files['stacks/local--web.md']).toContain('# Web'); expect(files['stacks/local--web-2.md']).toContain('# web'); // ...and the node page links to each distinct file. expect(files['nodes/local.md']).toContain('(../stacks/local--web.md)'); expect(files['nodes/local.md']).toContain('(../stacks/local--web-2.md)'); }); it('never leaks .env values and is deterministic', () => { const data = input(); // Even if anatomy somehow carried no values, prove the export contains only key names/counts. const a = buildFleetDossier(data); const b = buildFleetDossier(input()); expect(a['network.md']).toBe(b['network.md']); expect(a['index.md']).toBe(b['index.md']); expect(JSON.stringify(a)).not.toMatch(/CLAIM_TOKEN\s*=/); }); });