clawdie-ai/setup/preflight.test.ts
Operator & Codex d5c5c39144 Clean up system-namespace test debt (Sam & Codex)
Rewrite skipped PLATFORM_* identity tests against the current constant-based platform model, remove stale mock exports/comments, and delete the completed routing handoff.

---

Build: pass

Tests: pass — 2197 passed (164 files)

---
Build: pass | Tests: pass — 2197 passed (650 files)
2026-05-07 10:51:54 +02:00

341 lines
9.6 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
// Hoisted mocks — referenced in vi.mock factory functions below
const { spawnSyncMock, writeFileSyncMock, mkdirSyncMock } = vi.hoisted(() => ({
spawnSyncMock: vi.fn(() => ({
status: 0,
stdout: '',
stderr: '',
error: null,
})),
writeFileSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
}));
vi.mock('../src/config.js', () => ({
AGENT_INTERNAL_DOMAIN: 'clawdie.home.arpa',
PLATFORM_INTERNAL_BASE: 'home.arpa',
PLATFORM_PUBLIC_BASE: '',
DISPLAY_LOCALE: 'en-US',
TIMEZONE: 'UTC',
}));
vi.mock('../src/logger.js', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('./status.js', () => ({
emitStatus: vi.fn(),
}));
vi.mock('child_process', async () => {
const actual =
await vi.importActual<typeof import('child_process')>('child_process');
return { ...actual, spawnSync: spawnSyncMock };
});
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ''),
readdirSync: vi.fn(() => []),
cpSync: vi.fn(),
},
};
});
// process.exit must not terminate the test process
vi.spyOn(process, 'exit').mockImplementation((() => {}) as (
code?: string | number | null,
) => never);
// Pretend to be root so requiresRoot steps run via spawnSync (which is mocked)
vi.spyOn(process, 'getuid').mockReturnValue(0);
import { getPreflightTmuxSessionName, run } from './preflight.js';
// Non-interactive steps called by a basic preflight run
const BASIC_STEP_IDS = [
'environment',
'pi-config',
'pf',
'jails',
'db',
'controlplane',
'git',
'cms',
'hosts',
'mounts',
'telegram-auth',
'service',
'hostd',
'sanoid',
'verify',
'doctor',
];
const BASIC_STEP_COUNT = BASIC_STEP_IDS.length; // 16
type SpawnCall = [string, string[], (Record<string, unknown> | undefined)?];
type WriteCall = [string, string];
function getNpmCalls() {
return (spawnSyncMock.mock.calls as unknown as SpawnCall[]).filter(
(c) => c[0] === 'npm',
);
}
function getPythonCalls() {
return (spawnSyncMock.mock.calls as unknown as SpawnCall[]).filter(
(c) => c[0] === 'python3',
);
}
function getSummaryJson(): Record<string, unknown> | null {
const call = (writeFileSyncMock.mock.calls as unknown as WriteCall[]).find(
(c) => typeof c[0] === 'string' && String(c[0]).endsWith('summary.json'),
);
if (!call) return null;
return JSON.parse(String(call[1]));
}
function getSummaryEnv(): string | null {
const call = (writeFileSyncMock.mock.calls as unknown as WriteCall[]).find(
(c) => typeof c[0] === 'string' && String(c[0]).endsWith('summary.env'),
);
return call ? String(call[1]) : null;
}
beforeEach(() => {
spawnSyncMock.mockReset();
spawnSyncMock.mockReturnValue({
status: 0,
stdout: '',
stderr: '',
error: null,
});
writeFileSyncMock.mockReset();
mkdirSyncMock.mockReset();
vi.mocked(process.exit).mockClear();
});
describe('getPreflightTmuxSessionName', () => {
it('uses the platform service name for tmux targeting', () => {
expect(getPreflightTmuxSessionName()).toBe('clawdie');
});
});
describe('preflight — basic run (no flags)', () => {
it('runs all 16 steps in order', async () => {
await run([]);
const calls = getNpmCalls();
expect(calls).toHaveLength(BASIC_STEP_COUNT);
// Verify step order via args
expect(calls[0][1]).toContain('environment');
expect(calls[1][1]).toContain('pi-config');
expect(calls[2][1]).toContain('pf');
expect(calls[3][1]).toContain('jails');
expect(calls[4][1]).toContain('db');
expect(calls[5][1]).toContain('controlplane');
expect(calls[6][1]).toContain('git');
expect(calls[7][1]).toContain('cms');
expect(calls[8][1]).toContain('hosts');
expect(calls[9][1]).toContain('mounts');
expect(calls[10][1]).toContain('telegram-auth');
expect(calls[11][1]).toContain('service');
expect(calls[12][1]).toContain('hostd');
expect(calls[13][1]).toContain('sanoid');
expect(calls[14][1]).toContain('verify');
expect(calls[15][1]).toContain('doctor');
});
it('writes summary.json with overallStatus success', async () => {
await run([]);
const summary = getSummaryJson();
expect(summary).not.toBeNull();
expect(summary?.overallStatus).toBe('success');
expect(Array.isArray(summary?.results)).toBe(true);
expect((summary?.results as unknown[]).length).toBe(BASIC_STEP_COUNT);
});
it('writes summary.env with OVERALL_STATUS=success', async () => {
await run([]);
const env = getSummaryEnv();
expect(env).not.toBeNull();
expect(env).toContain('OVERALL_STATUS=success');
expect(env).toContain('WITH_ONBOARDING=false');
expect(env).toContain('CAPTURE_PASSWORD_STEP=false');
});
it('does not call process.exit when all steps pass', async () => {
await run([]);
expect(process.exit).not.toHaveBeenCalled();
});
});
describe('preflight — failure handling', () => {
it('runs all 15 steps even when one fails (no --fail-fast)', async () => {
// 3rd npm call (jails) fails
let callIdx = 0;
spawnSyncMock.mockImplementation(() => {
callIdx++;
return {
status: callIdx === 3 ? 1 : 0,
stdout: '',
stderr: '',
error: null,
};
});
await run([]);
expect(getNpmCalls()).toHaveLength(BASIC_STEP_COUNT);
});
it('writes summary.json with overallStatus failed when any step fails', async () => {
spawnSyncMock.mockReturnValueOnce({
status: 1,
stdout: '',
stderr: '',
error: null,
});
await run([]);
const summary = getSummaryJson();
expect(summary?.overallStatus).toBe('failed');
});
it('calls process.exit(1) when overall status is failed', async () => {
spawnSyncMock.mockReturnValueOnce({
status: 1,
stdout: '',
stderr: '',
error: null,
});
await run([]);
expect(process.exit).toHaveBeenCalledWith(1);
});
it('--fail-fast stops after first failing step', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stdout: '',
stderr: '',
error: null,
});
await run(['--fail-fast']);
expect(getNpmCalls()).toHaveLength(1);
expect(process.exit).toHaveBeenCalledWith(1);
});
});
describe('preflight — --with-onboarding', () => {
it('runs all 15 non-interactive steps (onboarding skipped without TTY)', async () => {
// In the test environment stdin/stdout are not TTYs, so the interactive
// onboarding step returns failed without calling spawnSync.
await run(['--with-onboarding']);
expect(getNpmCalls()).toHaveLength(BASIC_STEP_COUNT);
});
it('overall status is failed because onboarding step failed (no TTY)', async () => {
await run(['--with-onboarding']);
const summary = getSummaryJson();
expect(summary?.overallStatus).toBe('failed');
const onboardingResult = (
summary?.results as Array<{ id: string; status: string }>
)?.find((r) => r.id === 'onboarding');
expect(onboardingResult?.status).toBe('failed');
});
it('summary.env reflects WITH_ONBOARDING=true', async () => {
await run(['--with-onboarding']);
expect(getSummaryEnv()).toContain('WITH_ONBOARDING=true');
});
});
describe('preflight — --capture-password-step', () => {
it('implies --with-onboarding', async () => {
await run(['--capture-password-step']);
const summary = getSummaryJson();
expect(summary?.args).toMatchObject({
withOnboarding: true,
capturePasswordStep: true,
});
});
it('does NOT trigger screenshot capture when onboarding fails (no TTY)', async () => {
// onboarding is interactive → fails without TTY → capture must not run
await run(['--capture-password-step']);
expect(getPythonCalls()).toHaveLength(0);
});
it('summary.env reflects CAPTURE_PASSWORD_STEP=true', async () => {
await run(['--capture-password-step']);
expect(getSummaryEnv()).toContain('CAPTURE_PASSWORD_STEP=true');
});
});
describe('preflight — summary output structure', () => {
it('each step result in summary.json has expected fields', async () => {
await run([]);
const summary = getSummaryJson();
const results = summary?.results as Array<Record<string, unknown>>;
expect(results.length).toBe(BASIC_STEP_COUNT);
for (const result of results) {
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('commandLine');
expect(result).toHaveProperty('exitCode');
expect(result).toHaveProperty('status');
expect(result).toHaveProperty('startedAt');
expect(result).toHaveProperty('finishedAt');
expect(result).toHaveProperty('logFile');
}
});
it('step logFile paths in summary.json are relative to the run directory', async () => {
await run([]);
const summary = getSummaryJson();
const results = summary?.results as Array<{ logFile: string }>;
for (const result of results) {
// path.relative(runDir, logFile) → just the filename, e.g. "environment.log"
expect(result.logFile).not.toContain('/');
expect(result.logFile.endsWith('.log')).toBe(true);
}
});
it('summary.env contains a status line for every step', async () => {
await run([]);
const env = getSummaryEnv() ?? '';
for (const id of BASIC_STEP_IDS) {
const prefix = id.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
expect(env).toContain(`${prefix}_STATUS=`);
expect(env).toContain(`${prefix}_EXIT_CODE=`);
}
});
});