kimi-code/packages/kap-server/test/boot.test.ts
haozhe.yang 304883ab81 refactor(kap-server): rename server-v2 package to kap-server
- rename packages/server-v2 to packages/kap-server and update every
  @moonshot-ai/server-v2 reference across apps, packages, flake.nix,
  and build scripts
- remove the experimental `kimi v2` CLI prefix and its tests from the
  apps/kimi-code main entry
- add legacy BackgroundTask protocol types and the `blocked` turn
  interrupt reason for cross-engine compatibility
2026-07-07 16:56:19 +08:00

249 lines
7.9 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { createServer, type Server } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, describe, expect, it } from 'vitest';
import type { LockContents } from '../src/lock';
import { listenWithPortRetry, type RunningServer, startServer } from '../src/start';
import { authedFetch } from './helpers/auth';
describe('server-v2 boot', () => {
let server: RunningServer | undefined;
let home: string | undefined;
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
const base = `http://127.0.0.1:${server.port}`;
const healthz = await fetch(`${base}/api/v1/healthz`);
expect(healthz.status).toBe(200);
const healthBody = await healthz.json() as {
code: number;
data: { ok: boolean };
request_id: string;
};
expect(healthBody.code).toBe(0);
expect(healthBody.data.ok).toBe(true);
expect(typeof healthBody.request_id).toBe('string');
const meta = await authedFetch(server, base, '/api/v1/meta');
expect(meta.status).toBe(200);
const metaBody = await meta.json() as {
code: number;
data: { server_id: string; server_version: string; capabilities: Record<string, boolean> };
};
expect(metaBody.code).toBe(0);
expect(typeof metaBody.data.server_id).toBe('string');
expect(typeof metaBody.data.server_version).toBe('string');
expect(metaBody.data.capabilities).toBeDefined();
const auth = await authedFetch(server, base, '/api/v1/auth');
expect(auth.status).toBe(200);
const authBody = await auth.json() as {
code: number;
data: { ready: boolean; providers_count: number; default_model: string | null };
};
expect(authBody.code).toBe(0);
expect(typeof authBody.data.ready).toBe('boolean');
expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0);
// Poll with no flow in flight → null payload; exercises the v2 IOAuthService
// wiring without starting a real (networked) device-code flow.
const oauthPoll = await authedFetch(server, base, '/api/v1/oauth/login');
expect(oauthPoll.status).toBe(200);
const oauthBody = await oauthPoll.json() as { code: number; data: null };
expect(oauthBody.code).toBe(0);
expect(oauthBody.data).toBeNull();
});
});
function silentLogger() {
return pino({ level: 'silent' });
}
function addrInUse(): NodeJS.ErrnoException {
const err = new Error('listen EADDRINUSE') as NodeJS.ErrnoException;
err.code = 'EADDRINUSE';
return err;
}
function listenOnPort(host: string, port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen({ host, port }, () => resolve(server));
});
}
function closeNetServer(server: Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()));
}
/** Find `port` such that both `port` and `port + 1` are free to bind. */
async function allocateAdjacentFreePair(
host = '127.0.0.1',
): Promise<{ port: number; next: number }> {
for (let i = 0; i < 30; i++) {
const a = await listenOnPort(host, 0);
const address = a.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
await closeNetServer(a);
if (port <= 0 || port >= 65535) continue;
const probe = await listenOnPort(host, port + 1).catch(() => null);
if (probe === null) continue;
await closeNetServer(probe);
return { port, next: port + 1 };
}
throw new Error('could not allocate an adjacent free port pair');
}
describe('listenWithPortRetry', () => {
it('returns the requested port when the first listen succeeds', async () => {
const attempts: number[] = [];
const result = await listenWithPortRetry({
listen: async (_host, port) => {
attempts.push(port);
return `http://127.0.0.1:${String(port)}`;
},
host: '127.0.0.1',
port: 5000,
logger: silentLogger(),
});
expect(result.port).toBe(5000);
expect(attempts).toEqual([5000]);
});
it('retries with port+1 on EADDRINUSE until a bind succeeds', async () => {
const attempts: number[] = [];
const result = await listenWithPortRetry({
listen: async (_host, port) => {
attempts.push(port);
if (port < 5002) throw addrInUse();
return `http://127.0.0.1:${String(port)}`;
},
host: '127.0.0.1',
port: 5000,
logger: silentLogger(),
});
expect(result.port).toBe(5002);
expect(result.address).toBe('http://127.0.0.1:5002');
expect(attempts).toEqual([5000, 5001, 5002]);
});
it('does not retry on non-EADDRINUSE errors', async () => {
const attempts: number[] = [];
const boom = Object.assign(new Error('listen EACCES'), { code: 'EACCES' });
await expect(
listenWithPortRetry({
listen: async (_host, port) => {
attempts.push(port);
throw boom;
},
host: '127.0.0.1',
port: 5000,
logger: silentLogger(),
}),
).rejects.toBe(boom);
expect(attempts).toEqual([5000]);
});
it('throws after exhausting maxRetries', async () => {
const attempts: number[] = [];
await expect(
listenWithPortRetry({
listen: async (_host, port) => {
attempts.push(port);
throw addrInUse();
},
host: '127.0.0.1',
port: 5000,
logger: silentLogger(),
maxRetries: 3,
}),
).rejects.toMatchObject({ code: 'EADDRINUSE' });
// initial attempt + 3 retries, then the cap throws.
expect(attempts).toEqual([5000, 5001, 5002, 5003]);
});
it('does not walk ports when the requested port is 0 (ephemeral)', async () => {
const attempts: number[] = [];
const result = await listenWithPortRetry({
listen: async (_host, port) => {
attempts.push(port);
return 'http://127.0.0.1:54321';
},
host: '127.0.0.1',
port: 0,
logger: silentLogger(),
});
expect(result.port).toBe(0);
expect(attempts).toEqual([0]);
});
});
describe('server-v2 boot — port retry', () => {
let server: RunningServer | undefined;
let home: string | undefined;
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
it('retries on port+1 and advertises the bound port in the lock', async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-port-retry-'));
const { port, next } = await allocateAdjacentFreePair();
// Occupy the requested port with a raw TCP server (a "third-party" process
// from the server's point of view — it does NOT hold the lock).
const occupant = await listenOnPort('127.0.0.1', port);
try {
server = await startServer({
host: '127.0.0.1',
port,
homeDir: home,
logLevel: 'silent',
});
// Bound to the next available port (>= next); the lock advertises it so
// status/kill/ps work. On Windows a recently-closed probe port can linger
// in TIME_WAIT, so the retry may land on port+2 instead of port+1.
expect(server.port).toBeGreaterThanOrEqual(next);
const stored = JSON.parse(
await readFile(join(home, 'server', 'lock'), 'utf8'),
) as LockContents;
expect(stored.port).toBe(server.port);
} finally {
await closeNetServer(occupant);
}
});
});