import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildReseedCommand, buildRelaunchCommands, readRosterAgentNames, runFrameworkReseed, refreshActiveFleetUnits, readInstalledFrameworkVersion, readBundledFrameworkVersion, checkFrameworkDrift, repairFleetCommsTools, } from './update-checker.js'; /** * F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches * durable agents so shipped launcher/runtime changes activate. These cover the * pure builders + the missing-installer guard (the exec path is integration). */ describe('buildReseedCommand', () => { it('invokes the package install.sh in data-safe sync-only keep mode', () => { const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic'); expect(out.installer).toBe('/pkg/framework/install.sh'); expect(out.command).toBe('bash /pkg/framework/install.sh'); expect(out.env).toEqual({ MOSAIC_SYNC_ONLY: '1', MOSAIC_INSTALL_MODE: 'keep', MOSAIC_HOME: '/home/u/.config/mosaic', }); }); }); describe('buildRelaunchCommands', () => { it('builds a systemctl --user restart per agent unit', () => { expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([ ['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'], ['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'], ]); }); it('is empty for an empty roster', () => { expect(buildRelaunchCommands([])).toEqual([]); }); }); describe('readRosterAgentNames', () => { let home: string; beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'mosaic-roster-')); }); afterEach(() => { rmSync(home, { recursive: true, force: true }); }); it('returns [] when no roster exists', () => { expect(readRosterAgentNames(home)).toEqual([]); }); it('extracts agent names from roster.yaml', () => { mkdirSync(join(home, 'fleet'), { recursive: true }); writeFileSync( join(home, 'fleet', 'roster.yaml'), [ 'version: 1', 'transport: tmux', 'agents:', ' - name: orchestrator', ' runtime: pi', ' - name: coder0', ' runtime: claude', ' - name: "reviewer-1"', ' runtime: codex', ].join('\n') + '\n', ); expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']); }); it('extracts agent names from a JSON-only roster', () => { mkdirSync(join(home, 'fleet'), { recursive: true }); writeFileSync( join(home, 'fleet', 'roster.json'), JSON.stringify({ version: 1, transport: 'tmux', agents: [ { name: 'orchestrator', runtime: 'pi', class: 'orchestrator' }, { name: 'coder0', runtime: 'claude', class: 'worker' }, ], }), ); expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']); }); }); describe('repairFleetCommsTools', () => { let root: string; let framework: string; let home: string; const toolsContent = '# tools\n\n'; const helperContent = '#!/bin/sh\nexit 0\n'; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-')); framework = join(root, 'framework'); home = join(root, 'home'); mkdirSync(join(framework, 'defaults'), { recursive: true }); mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true }); writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent); const helper = join(framework, 'tools', 'tmux', 'agent-send.sh'); writeFileSync(helper, helperContent); chmodSync(helper, 0o755); }); afterEach(() => rmSync(root, { recursive: true, force: true })); it('restores a partially deleted current-version installation without package updates', () => { mkdirSync(join(home, 'tools', 'tmux'), { recursive: true }); writeFileSync(join(home, 'TOOLS.md'), toolsContent); const result = repairFleetCommsTools(framework, home); expect(result).toMatchObject({ ok: true, changed: true }); expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent); expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent); expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0); }); it('creates a digest-qualified no-clobber backup and is idempotent', () => { mkdirSync(home, { recursive: true }); const stale = '# user tools\n'; writeFileSync(join(home, 'TOOLS.md'), stale); const first = repairFleetCommsTools(framework, home); expect(first).toMatchObject({ ok: true, changed: true }); expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/); expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale); const second = repairFleetCommsTools(framework, home); expect(second).toEqual({ ok: true, changed: false, backupPath: undefined }); expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale); }); it('rejects an installed helper symlink without modifying its target', () => { mkdirSync(join(home, 'tools', 'tmux'), { recursive: true }); writeFileSync(join(home, 'TOOLS.md'), toolsContent); const target = join(root, 'external-helper'); writeFileSync(target, 'do not touch\n'); symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh')); const result = repairFleetCommsTools(framework, home); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.reason).toContain('symbolic link'); expect(readFileSync(target, 'utf8')).toBe('do not touch\n'); expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true); }); it('refuses a helper directory before replacing stale TOOLS content', () => { mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true }); const stale = '# user tools\n'; writeFileSync(join(home, 'TOOLS.md'), stale); const result = repairFleetCommsTools(framework, home); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.reason).toContain('not a regular file'); expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale); }); it('refuses a pre-existing digest backup whose bytes do not match', () => { mkdirSync(home, { recursive: true }); const stale = '# user tools\n'; writeFileSync(join(home, 'TOOLS.md'), stale); const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16); writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n'); const result = repairFleetCommsTools(framework, home); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.reason).toContain('backup collision'); expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale); }); it('rejects a symlink in each installed destination ancestor without external writes', () => { const cases = [ { name: 'home', prefix: join(root, 'linked-home'), suffix: '' }, { name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' }, { name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') }, ]; for (const testCase of cases) { const external = join(root, `external-${testCase.name}`); mkdirSync(external, { recursive: true }); const targetHome = testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`); if (testCase.name === 'home') { symlinkSync(external, targetHome); } else { mkdirSync(targetHome, { recursive: true }); const linkPath = join(targetHome, testCase.suffix); mkdirSync(join(linkPath, '..'), { recursive: true }); symlinkSync(external, linkPath); } const result = repairFleetCommsTools(framework, targetHome); expect(result, testCase.name).toMatchObject({ ok: false, changed: false }); expect(result.reason, testCase.name).toContain('symbolic link'); expect(readdirSync(external), testCase.name).toEqual([]); } }); it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => { mkdirSync(join(home, 'tools', 'tmux'), { recursive: true }); const staleTools = '# user tools\n'; const staleHelper = '#!/bin/sh\nexit 17\n'; writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 }); writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 }); const result = repairFleetCommsTools(framework, home, { beforeCommit(which) { if (which === 'helper') throw new Error('injected helper commit failure'); }, }); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.backupPath).toBeUndefined(); expect(result.reason).toContain('injected helper commit failure'); expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools); expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640); expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper); expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710); expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]); expect( readdirSync(home).some((name) => name.includes('.repair-')) || readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')), ).toBe(false); }); it('rolls back initially absent destinations and created directories on commit failure', () => { const result = repairFleetCommsTools(framework, home, { beforeCommit(which) { if (which === 'helper') throw new Error('injected absent helper failure'); }, }); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.reason).toContain('injected absent helper failure'); expect(existsSync(home)).toBe(false); }); it('does not persist a backup or replacement when backup commit fails', () => { mkdirSync(home, { recursive: true }); const stale = '# user tools\n'; writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 }); const result = repairFleetCommsTools(framework, home, { beforeCommit(which) { if (which === 'backup') throw new Error('injected backup commit failure'); }, }); expect(result).toMatchObject({ ok: false, changed: false }); expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale); expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640); expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]); }); it('fails before writes when bundled source paths traverse a symlink ancestor', () => { const external = join(root, 'external-source'); mkdirSync(join(external, 'defaults'), { recursive: true }); mkdirSync(join(external, 'tools', 'tmux'), { recursive: true }); writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent); writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 }); const linkedFramework = join(root, 'linked-framework'); symlinkSync(external, linkedFramework); const result = repairFleetCommsTools(linkedFramework, home); expect(result).toMatchObject({ ok: false, changed: false }); expect(result.reason).toContain('symbolic link'); expect(existsSync(home)).toBe(false); }); }); describe('runFrameworkReseed', () => { it('reports not-ok (not throw) when the installer is absent', () => { const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-')); const res = runFrameworkReseed(missing, join(missing, 'home')); expect(res.ok).toBe(false); expect(res.reason).toContain('installer not found'); rmSync(missing, { recursive: true, force: true }); }); }); describe('refreshActiveFleetUnits', () => { let root: string; let mosaicHome: string; let configHome: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'mosaic-units-')); mosaicHome = join(root, 'mosaic'); configHome = join(root, 'config'); mkdirSync(join(mosaicHome, 'systemd', 'user'), { recursive: true }); mkdirSync(join(configHome, 'systemd', 'user'), { recursive: true }); // Freshly re-seeded units (new content). writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-agent@.service'), 'NEW\n'); writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-tmux-holder.service'), 'NEW\n'); }); afterEach(() => rmSync(root, { recursive: true, force: true })); it('refreshes active units when a fleet is already installed', () => { // Active dir already carries mosaic units (stale) → fleet is installed. writeFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'OLD\n'); const res = refreshActiveFleetUnits(mosaicHome, { XDG_CONFIG_HOME: configHome, } as NodeJS.ProcessEnv); expect(res.refreshed).toContain('mosaic-agent@.service'); expect( readFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'utf-8'), ).toBe('NEW\n'); }); it('is a no-op when no fleet is installed (active dir has no mosaic units)', () => { const res = refreshActiveFleetUnits(mosaicHome, { XDG_CONFIG_HOME: configHome, } as NodeJS.ProcessEnv); expect(res.refreshed).toEqual([]); expect(existsSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'))).toBe(false); }); }); /** * #642: re-seed when the on-disk framework is older than the bundled one even * if no package is reported outdated (CLI upgraded outside `mosaic update`). */ describe('framework drift detection', () => { let home: string; // stand-in for ~/.config/mosaic let fw: string; // stand-in for the bundled framework root beforeEach(() => { const root = mkdtempSync(join(tmpdir(), 'mosaic-drift-')); home = join(root, 'mosaic'); fw = join(root, 'framework'); mkdirSync(home, { recursive: true }); mkdirSync(fw, { recursive: true }); }); afterEach(() => { rmSync(join(home, '..'), { recursive: true, force: true }); }); const writeInstalled = (v: string) => writeFileSync(join(home, '.framework-version'), v); const writeBundled = (v: string) => writeFileSync(join(fw, 'install.sh'), `#!/usr/bin/env bash\nFRAMEWORK_VERSION=${v}\n`); describe('readInstalledFrameworkVersion', () => { it('returns undefined when the version file is absent', () => { expect(readInstalledFrameworkVersion(home)).toBeUndefined(); }); it('parses the integer (tolerating surrounding whitespace)', () => { writeInstalled(' 3\n'); expect(readInstalledFrameworkVersion(home)).toBe(3); }); it('returns undefined for non-numeric content', () => { writeInstalled('not-a-number\n'); expect(readInstalledFrameworkVersion(home)).toBeUndefined(); }); }); describe('readBundledFrameworkVersion', () => { it('returns undefined when install.sh is absent', () => { expect(readBundledFrameworkVersion(fw)).toBeUndefined(); }); it('parses FRAMEWORK_VERSION= from install.sh', () => { writeBundled('4'); expect(readBundledFrameworkVersion(fw)).toBe(4); }); }); describe('checkFrameworkDrift', () => { it('reports drift when on-disk is older than bundled', () => { writeInstalled('3'); writeBundled('4'); expect(checkFrameworkDrift(home, fw)).toEqual({ drifted: true, installed: 3, bundled: 4 }); }); it('no drift when versions match', () => { writeInstalled('4'); writeBundled('4'); expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false }); }); it('no drift when on-disk is newer than bundled', () => { writeInstalled('5'); writeBundled('4'); expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false }); }); it('no drift (conservative) when a version cannot be read', () => { writeBundled('4'); // installed version file missing expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false, bundled: 4 }); }); }); });