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)
341 lines
9.6 KiB
TypeScript
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=`);
|
|
}
|
|
});
|
|
});
|