This commit is contained in:
219
packages/server/tests/first-run.test.ts
Normal file
219
packages/server/tests/first-run.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* First-run detection and startup progress event tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { startService, isFirstRun } from '../src/local/service.js';
|
||||
import type { StartupEvent, StartupPhase } from '../src/local/service.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-firstrun-test-'));
|
||||
}
|
||||
|
||||
// Pick an OS-assigned ephemeral port by briefly binding to port 0.
|
||||
// Falls back to random high port if probing fails. This avoids the
|
||||
// collisions we saw when multiple test files ran in parallel with a
|
||||
// narrow random range.
|
||||
import net from 'node:net';
|
||||
function randomPort(): number {
|
||||
try {
|
||||
const server = net.createServer();
|
||||
server.listen(0);
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
server.close();
|
||||
if (port > 0) return port;
|
||||
} catch { /* fall through */ }
|
||||
return 20_000 + Math.floor(Math.random() * 40_000);
|
||||
}
|
||||
|
||||
// ── isFirstRun ─────────────────────────────────────────────────────
|
||||
|
||||
describe('isFirstRun', () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
it('returns true for empty directory (fresh install)', () => {
|
||||
const dir = makeTmpDir();
|
||||
tmpDirs.push(dir);
|
||||
expect(isFirstRun(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when personal.mind exists', () => {
|
||||
const dir = makeTmpDir();
|
||||
tmpDirs.push(dir);
|
||||
const mind = new MindDB(path.join(dir, 'personal.mind'));
|
||||
mind.close();
|
||||
expect(isFirstRun(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when default.mind exists (needs migration)', () => {
|
||||
const dir = makeTmpDir();
|
||||
tmpDirs.push(dir);
|
||||
const mind = new MindDB(path.join(dir, 'default.mind'));
|
||||
mind.close();
|
||||
expect(isFirstRun(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when directory does not exist yet', () => {
|
||||
const dir = path.join(os.tmpdir(), 'waggle-nonexistent-' + Date.now());
|
||||
// Don't create it
|
||||
expect(isFirstRun(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── onProgress callback ────────────────────────────────────────────
|
||||
|
||||
describe('startService onProgress', () => {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanups.length = 0;
|
||||
for (const dir of tmpDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
it('emits progress events during startup (fresh install)', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = randomPort();
|
||||
const events: StartupEvent[] = [];
|
||||
|
||||
const { server } = await startService({
|
||||
dataDir,
|
||||
port,
|
||||
skipLiteLLM: true,
|
||||
onProgress: (event) => events.push(event),
|
||||
});
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
// Should have emitted at least init and ready
|
||||
const phases = events.map(e => e.phase);
|
||||
expect(phases).toContain('init');
|
||||
expect(phases).toContain('ready');
|
||||
|
||||
// Should NOT have migration phase (fresh install, no default.mind)
|
||||
expect(phases).not.toContain('migration');
|
||||
|
||||
// Should have creating-mind phase (fresh install)
|
||||
expect(phases).toContain('creating-mind');
|
||||
|
||||
// Progress should go from 0 to 1
|
||||
expect(events[0].progress).toBeLessThanOrEqual(0.2);
|
||||
expect(events[events.length - 1].progress).toBe(1);
|
||||
|
||||
// All events should have messages
|
||||
for (const event of events) {
|
||||
expect(event.message).toBeTruthy();
|
||||
expect(typeof event.progress).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('emits migration phase when default.mind exists', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
|
||||
// Create default.mind to trigger migration
|
||||
const mind = new MindDB(path.join(dataDir, 'default.mind'));
|
||||
mind.close();
|
||||
|
||||
const port = randomPort();
|
||||
const events: StartupEvent[] = [];
|
||||
|
||||
const { server } = await startService({
|
||||
dataDir,
|
||||
port,
|
||||
skipLiteLLM: true,
|
||||
onProgress: (event) => events.push(event),
|
||||
});
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
const phases = events.map(e => e.phase);
|
||||
expect(phases).toContain('migration');
|
||||
expect(phases).toContain('ready');
|
||||
});
|
||||
|
||||
it('emits litellm phase when not skipped (but still skips in test)', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = randomPort();
|
||||
const events: StartupEvent[] = [];
|
||||
|
||||
const { server } = await startService({
|
||||
dataDir,
|
||||
port,
|
||||
skipLiteLLM: true,
|
||||
onProgress: (event) => events.push(event),
|
||||
});
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
const phases = events.map(e => e.phase);
|
||||
// Even when skipped, the litellm phase should be emitted
|
||||
expect(phases).toContain('litellm');
|
||||
});
|
||||
|
||||
it('emits server phase', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = randomPort();
|
||||
const events: StartupEvent[] = [];
|
||||
|
||||
const { server } = await startService({
|
||||
dataDir,
|
||||
port,
|
||||
skipLiteLLM: true,
|
||||
onProgress: (event) => events.push(event),
|
||||
});
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
const phases = events.map(e => e.phase);
|
||||
expect(phases).toContain('server');
|
||||
});
|
||||
|
||||
it('works without onProgress callback (backward compat)', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = randomPort();
|
||||
|
||||
// Should not throw when no callback
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
expect(server.server.listening).toBe(true);
|
||||
});
|
||||
|
||||
it('progress values are monotonically increasing', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = randomPort();
|
||||
const events: StartupEvent[] = [];
|
||||
|
||||
const { server } = await startService({
|
||||
dataDir,
|
||||
port,
|
||||
skipLiteLLM: true,
|
||||
onProgress: (event) => events.push(event),
|
||||
});
|
||||
cleanups.push(async () => { await server.close(); });
|
||||
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].progress).toBeGreaterThanOrEqual(events[i - 1].progress);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user