import { afterEach, describe, expect, it } from 'vitest'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { detectPlatform, parseInstallArgs, buildServiceArgs, resolveBin, resolveScriptPath, renderSystemdUnit, renderLaunchAgentPlist, xmlEscape, systemdUnitPath, launchAgentPlistPath, } from '../src/cli/install.js'; describe('cli install — detectPlatform', () => { it('accepte linux et darwin', () => { expect(detectPlatform('linux')).toBe('linux'); expect(detectPlatform('darwin')).toBe('darwin'); }); it('rejette les autres plateformes avec un message clair', () => { expect(() => detectPlatform('win32')).toThrow(/Linux \(systemd\) and macOS \(launchd\)/); expect(() => detectPlatform('freebsd')).toThrow(/freebsd/); }); }); describe('cli install — buildServiceArgs', () => { it('aucun flag → aucun argument (le service garde les défauts loopback)', () => { expect(buildServiceArgs(parseInstallArgs([]))).toEqual([]); }); it('propage --port et --allow-origin (répétable) dans un ordre stable', () => { const flags = parseInstallArgs(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']); expect(buildServiceArgs(flags)).toEqual(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']); }); it("n'injecte JAMAIS --i-know-this-exposes-a-terminal (modèle de sécurité)", () => { const flags = parseInstallArgs(['--bind', '0.0.0.0', '--port', '7317']); expect(buildServiceArgs(flags)).not.toContain('--i-know-this-exposes-a-terminal'); }); it("ne propage pas les flags propres à l'install (bin-path, label, dry-run, no-enable)", () => { const flags = parseInstallArgs(['--bin-path', '/usr/local/bin/arboretum', '--label', 'x', '--dry-run', '--no-enable']); expect(buildServiceArgs(flags)).toEqual([]); }); }); describe('cli install — resolveBin', () => { it('par défaut : node (process.execPath) + dist/index.js', () => { const { exec, args } = resolveBin({}); expect(exec).toBe(process.execPath); expect(args).toHaveLength(1); expect(args[0]).toBe(resolveScriptPath()); expect(args[0]).toMatch(/index\.(js|ts)$/); }); it('--bin-path force le wrapper, sans argument de script', () => { expect(resolveBin({ binPath: '/usr/local/bin/arboretum' })).toEqual({ exec: '/usr/local/bin/arboretum', args: [], }); }); }); describe('cli install — renderSystemdUnit', () => { it('contient le ExecStart calculé et les directives clés', () => { const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/opt/arboretum/index.js', '--port', '7317'] }); expect(unit).toContain('ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317'); expect(unit).toContain('Restart=on-failure'); expect(unit).toContain('KillSignal=SIGTERM'); expect(unit).toContain('TimeoutStopSec=10'); expect(unit).toContain('WantedBy=default.target'); }); it('quote les tokens contenant un espace', () => { const unit = renderSystemdUnit({ exec: '/path with space/node', scriptArgs: ['/s.js'] }); expect(unit).toContain('ExecStart="/path with space/node" /s.js'); }); it('snapshot du unit pour un jeu de flags fixe', () => { const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/opt/arboretum/index.js', '--port', '7317', '--allow-origin', 'https://m.ts.net'], }); expect(unit).toMatchInlineSnapshot(` "[Unit] Description=Arboretum — git worktree & Claude Code dashboard After=network-online.target Wants=network-online.target [Service] ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317 --allow-origin https://m.ts.net Restart=on-failure RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=10 Environment=NODE_ENV=production [Install] WantedBy=default.target " `); }); }); describe('cli install — renderLaunchAgentPlist', () => { const base = { label: 'fr.lidge.arboretum', programArguments: ['/usr/bin/node', '/opt/index.js', '--port', '7317'], stdoutPath: '/Users/me/Library/Logs/arboretum/out.log', stderrPath: '/Users/me/Library/Logs/arboretum/err.log', }; it('contient le label, les ProgramArguments ordonnés et les clés launchd', () => { const plist = renderLaunchAgentPlist(base); expect(plist).toContain('fr.lidge.arboretum'); const idxNode = plist.indexOf('/usr/bin/node'); const idxScript = plist.indexOf('/opt/index.js'); expect(idxNode).toBeGreaterThan(0); expect(idxScript).toBeGreaterThan(idxNode); expect(plist).toContain('RunAtLoad'); expect(plist).toContain('KeepAlive'); expect(plist).toContain('StandardOutPath'); }); it('échappe les caractères XML dans les arguments (URL avec &)', () => { const plist = renderLaunchAgentPlist({ ...base, programArguments: ['/usr/bin/node', '/opt/index.js', '--allow-origin', 'https://a?b&c=d'], }); expect(plist).toContain('https://a?b&c=d'); expect(plist).not.toContain('b&c=d'); }); }); describe('cli install — xmlEscape', () => { it('échappe &, < et >', () => { expect(xmlEscape('a & b < c > d')).toBe('a & b < c > d'); }); }); describe('cli install — chemins', () => { const savedXdg = process.env.XDG_CONFIG_HOME; afterEach(() => { if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = savedXdg; }); it('systemdUnitPath respecte XDG_CONFIG_HOME', () => { process.env.XDG_CONFIG_HOME = '/tmp/xdg'; expect(systemdUnitPath()).toBe('/tmp/xdg/systemd/user/arboretum.service'); }); it('systemdUnitPath retombe sur ~/.config sans XDG_CONFIG_HOME', () => { delete process.env.XDG_CONFIG_HOME; expect(systemdUnitPath()).toBe(join(homedir(), '.config', 'systemd', 'user', 'arboretum.service')); }); it('launchAgentPlistPath place le plist dans ~/Library/LaunchAgents', () => { expect(launchAgentPlistPath('fr.lidge.arboretum')).toBe( join(homedir(), 'Library', 'LaunchAgents', 'fr.lidge.arboretum.plist'), ); }); });