SPB Git

Repository Public

main.test.ts 14.2 KB 308 lines typescript

1import fs from 'node:fs';2import os from 'node:os';3import path from 'node:path';4import { spawn } from 'node:child_process';5import { fileURLToPath } from 'node:url';6import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';7import type { RtSessionInfo } from '@cr/shared';8import { bearerToken, classifyToken, safeEqual } from './auth.js';9import { Backpressure } from './core-backpressure.js';10import { EventBus } from './events.js';11import { buildRuntime, type Runtime } from './main.js';12import { aggregateTree, cpuPctBetween, parseMeminfo, parsePs, parseVmStat } from './metrics.js';13import { cleanUserEnv, sanitizedBaseEnv, shQuote } from './core-util.js';14import { collectEvents, makeTestEnv, openWs, request, sessionSpec, startRuntime, TermClient, waitUntil, type TestEnv } from './core-testkit.js';1516describe('auth primitives', () => {17  it('compares tokens safely and parses bearer headers', () => {18    expect(safeEqual('abc', 'abc')).toBe(true);19    expect(safeEqual('abc', 'abd')).toBe(false);20    expect(safeEqual('abc', 'abcd')).toBe(false);21    expect(bearerToken('Bearer tok')).toBe('tok');22    expect(bearerToken('bearer   tok')).toBe('tok');23    expect(bearerToken('Basic tok')).toBeNull();24    expect(bearerToken(undefined)).toBeNull();25    expect(bearerToken('Bearer a b')).toBeNull();26    expect(classifyToken('r', 'r', 'h')).toBe('runtime');27    expect(classifyToken('h', 'r', 'h')).toBe('hook');28    expect(classifyToken('x', 'r', 'h')).toBeNull();29    expect(classifyToken(null, 'r', 'h')).toBeNull();30  });31});3233describe('environment hygiene + quoting', () => {34  it('strips runtime secrets and reserved names', () => {35    const env = sanitizedBaseEnv({ PATH: '/bin', CR_RUNTIME_TOKEN: 't', DATABASE_URL: 'd', CR_SECRET_KEY: 'k', CR_AGENTD_TOKEN: 'a', TMUX: 'x', HOME: '/h' });36    expect(env).toEqual({ PATH: '/bin', HOME: '/h' });37    expect(cleanUserEnv({ A: '1', CR_HOOK_TOKEN: 'spoof', DATABASE_URL: 'x' })).toEqual({ A: '1' });38    expect(() => cleanUserEnv({ 'A=B': '1' })).toThrow();39    expect(() => cleanUserEnv({ A: 'x\0y' })).toThrow();40    expect(shQuote("a'b")).toBe(`'a'\\''b'`);41  });42});4344describe('Backpressure', () => {45  it('pauses above the high-water mark, resumes when drained, and gives up after the saturation window', () => {46    vi.useFakeTimers();47    try {48      let buffered = 0;49      const calls: string[] = [];50      const make = () =>51        new Backpressure({52          highWaterBytes: 100,53          buffered: () => buffered,54          pause: () => calls.push('pause'),55          resume: () => calls.push('resume'),56          onSaturated: () => calls.push('saturated'),57        });58      const bp = make();59      buffered = 100;60      bp.check();61      expect(calls).toEqual([]); // not above the mark62      buffered = 101;63      bp.check();64      bp.check();65      expect(calls).toEqual(['pause']);66      buffered = 60; // still above half: stays paused67      vi.advanceTimersByTime(1000);68      expect(calls).toEqual(['pause']);69      buffered = 50;70      vi.advanceTimersByTime(60);71      expect(calls).toEqual(['pause', 'resume']);72      expect(bp.paused).toBe(false);7374      buffered = 500; // saturated for 30 s ⇒ onSaturated exactly once (caller closes 4429)75      bp.check();76      vi.advanceTimersByTime(29_900);77      expect(calls).toEqual(['pause', 'resume', 'pause']);78      vi.advanceTimersByTime(200);79      expect(calls).toEqual(['pause', 'resume', 'pause', 'saturated']);80      vi.advanceTimersByTime(60_000);81      bp.check();82      expect(calls).toHaveLength(4);83    } finally {84      vi.useRealTimers();85    }86  });87});8889describe('EventBus', () => {90  it('validates, fans out, isolates failing listeners and unsubscribes', () => {91    const bus = new EventBus();92    const seen: unknown[] = [];93    bus.subscribe(() => {94      throw new Error('boom');95    });96    const off = bus.subscribe((e) => seen.push(e));97    bus.emit({ type: 'session.exit', id: 'a', exitCode: 1 });98    off();99    bus.emit({ type: 'session.exit', id: 'b', exitCode: null });100    expect(seen).toEqual([{ type: 'session.exit', id: 'a', exitCode: 1 }]);101    // Not a valid RtEvent ⇒ refused at the source.102    expect(() => bus.emit({ type: 'nope' } as never)).toThrow();103  });104});105106describe('metrics parsers', () => {107  it('computes cpu%, memory and process-tree aggregates from real formats', () => {108    expect(cpuPctBetween({ atMs: 0, idle: 100, total: 200 }, { atMs: 1, idle: 150, total: 400 })).toBe(75);109    expect(cpuPctBetween({ atMs: 0, idle: 1, total: 2 }, { atMs: 1, idle: 1, total: 2 })).toBeNull();110    const vm = 'Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free:      1000.\nPages active:    200.\nPages wired down:   50.\nPages occupied by compressor:   25.\n';111    expect(parseVmStat(vm)).toBe(275 * 16384);112    expect(parseVmStat('garbage')).toBeNull();113    expect(parseMeminfo('MemTotal:       1000 kB\nMemFree: 1 kB\nMemAvailable:    400 kB\n')).toBe(600 * 1024);114    expect(parseMeminfo('nothing')).toBeNull();115    const rows = parsePs('  10   1  1,5  2048\n  11  10  2.5  1024\n  12  11  0.0  1024\n  99   1  9.0  4096\n junk\n');116    expect(rows).toHaveLength(4);117    expect(aggregateTree(rows, 10)).toEqual({ cpuPct: 4, rssMb: 4 });118    expect(aggregateTree(rows, 12345)).toBeNull();119  });120});121122describe('runtime process (unix socket, auth, errors, health, metrics)', () => {123  let env: TestEnv;124  let rt: Runtime;125  let sock: string;126  let token: string;127  beforeAll(async () => {128    env = makeTestEnv();129    rt = await startRuntime(env.config);130    sock = env.config.socketPath;131    token = env.config.token;132  });133  afterAll(async () => {134    await rt.stop();135    env.cleanup();136  });137138  it('listens only on a 0600 unix socket inside a 0700 directory', () => {139    const st = fs.statSync(sock);140    expect(st.isSocket()).toBe(true);141    expect(st.mode & 0o777).toBe(0o600);142    expect(fs.statSync(path.dirname(sock)).mode & 0o777).toBe(0o700);143    const address = rt.app.server.address();144    expect(address).toBe(sock); // a string path, not a TCP {address, port}145  });146147  it('requires the bearer token on every HTTP route and WebSocket upgrade', async () => {148    for (const url of ['/health', '/sessions', '/tasks', '/host/metrics', '/nope']) {149      expect((await request(sock, 'GET', url, { token: null })).status, url).toBe(401);150      const wrong = await request(sock, 'GET', url, { token: `${token.slice(0, -1)}x` });151      expect(wrong.status, url).toBe(401);152      expect(wrong.json<{ error: { code: string } }>().error.code).toBe('unauthorized');153    }154    expect((await request(sock, 'GET', '/health', { headers: { authorization: `Basic ${token}` } })).status).toBe(401);155    for (const url of ['/events', '/sessions/00000000-0000-4000-8000-000000000000/attach', '/tasks/00000000-0000-4000-8000-000000000000/log/follow']) {156      await expect(openWs(sock, url, null), url).rejects.toThrow('HTTP 401');157      await expect(openWs(sock, url, 'wrong-token'), url).rejects.toThrow('HTTP 401');158      await expect(openWs(sock, url, rt.core.hookToken), url).rejects.toThrow('HTTP 401');159    }160    const ok = await openWs(sock, '/events', token);161    ok.close();162  });163164  it('maps errors to {error:{code,message}}', async () => {165    const notFound = await request(sock, 'GET', '/nope', { token });166    expect(notFound.status).toBe(404);167    expect(notFound.json()).toEqual({ error: { code: 'not_found', message: 'no such route' } });168    const zod = await request(sock, 'POST', '/sessions', { token, body: { id: 'x' } });169    expect(zod.status).toBe(400);170    expect(zod.json<{ error: { code: string; message: string } }>().error.code).toBe('bad_request');171    const rtErr = await request(sock, 'GET', '/sessions/00000000-0000-4000-8000-000000000000', { token });172    expect(rtErr.status).toBe(404);173    expect(rtErr.json()).toEqual({ error: { code: 'not_found', message: 'no such session' } });174    const badJson = await request(sock, 'POST', '/sessions', { token, rawBody: '{ not json', headers: { 'content-type': 'application/json' } });175    expect(badJson.status).toBe(400);176    expect(badJson.json<{ error: { code: string } }>().error.code).toBe('bad_request');177  });178179  it('GET /health reports real component state', async () => {180    const h = (await request(sock, 'GET', '/health', { token })).json<{ ok: boolean; version: string; tmux: boolean; claude: { path: string; version: string | null }; uptimeSec: number }>();181    expect(h.ok).toBe(true);182    expect(h.version).toMatch(/^\d+\.\d+\.\d+/);183    expect(h.tmux).toBe(true);184    expect(typeof h.claude.path).toBe('string');185    expect(h.claude.version === null || h.claude.version.length > 0).toBe(true);186    expect(h.uptimeSec).toBeGreaterThanOrEqual(0);187  });188189  it('GET /host/metrics returns measured values, including per-session process trees', async () => {190    const id = (await request(sock, 'POST', '/sessions', { token, body: sessionSpec(env) })).json<RtSessionInfo>().id;191    await new Promise((r) => setTimeout(r, 400));192    const before = Date.now();193    const m = (await request(sock, 'GET', '/host/metrics', { token })).json<{194      collectedAtMs: number; hostname: string; cpuPct: number | null; load: number[]; memUsedMb: number | null; memTotalMb: number;195      diskFreeGb: number | null; diskTotalGb: number | null; uptimeSec: number; sessions: { id: string; cpuPct: number | null; rssMb: number | null }[];196    }>();197    expect(m.collectedAtMs).toBeGreaterThanOrEqual(before);198    expect(m.hostname).toBe(os.hostname());199    expect(m.cpuPct).not.toBeNull();200    expect(m.cpuPct!).toBeGreaterThanOrEqual(0);201    expect(m.cpuPct!).toBeLessThanOrEqual(100);202    expect(m.load).toHaveLength(3);203    expect(m.memTotalMb).toBe(Math.round(os.totalmem() / 1048576));204    expect(m.memUsedMb!).toBeGreaterThan(0);205    expect(m.memUsedMb!).toBeLessThanOrEqual(m.memTotalMb);206    expect(m.diskTotalGb!).toBeGreaterThan(0);207    expect(m.diskFreeGb!).toBeLessThanOrEqual(m.diskTotalGb!);208    expect(Math.abs(m.uptimeSec - os.uptime())).toBeLessThan(5);209    const s = m.sessions.find((x) => x.id === id);210    expect(s).toBeDefined();211    expect(s!.rssMb!).toBeGreaterThan(0); // a live shell has a resident set212    expect(s!.cpuPct!).toBeGreaterThanOrEqual(0);213    await request(sock, 'DELETE', `/sessions/${id}?force=1`, { token });214  });215216  it('graceful stop detaches clients (1001) and leaves the tmux session running', async () => {217    const env2 = makeTestEnv();218    const first = await startRuntime(env2.config);219    const s2 = env2.config.socketPath;220    const t2 = env2.config.token;221    try {222      const id = (await request(s2, 'POST', '/sessions', { token: t2, body: sessionSpec(env2) })).json<RtSessionInfo>().id;223      const client = await TermClient.open(s2, id, t2);224      await client.waitMsg('ready');225      client.type('echo up-$((1+1))\n');226      await client.waitOutput('up-2');227      const oldHookToken = first.core.hookToken;228      await first.stop();229      expect(await client.waitClose()).toBe(1001);230      expect(fs.existsSync(s2)).toBe(false);231      const row = await first.core.tmux.getSession(`cr-${id}`);232      expect(row).toMatchObject({ paneDead: false });233      await waitUntil(async () => (await first.core.tmux.getSession(`cr-${id}`))?.attached === 0, 5000, 'attach client gone');234235      const second = await startRuntime(env2.config);236      try {237        expect((await request(s2, 'GET', `/sessions/${id}`, { token: t2 })).json<RtSessionInfo>()).toMatchObject({ id, alive: true });238        expect(second.core.hookToken).toBe(oldHookToken); // kept: the surviving session still holds it239      } finally {240        await second.stop();241      }242    } finally {243      env2.cleanup();244    }245  });246247  it('refuses to start a second runtime on a live socket', async () => {248    const second = await buildRuntime({ ...env.config, tmuxSocketName: env.config.tmuxSocketName }, { plugins: 'core' });249    await expect(second.start()).rejects.toThrow(/already listening/);250    await second.stop();251    expect((await request(sock, 'GET', '/health', { token })).status).toBe(200); // the first one is untouched252  });253});254255describe('tmux survives the death of the runtime process', () => {256  it('SIGKILL of a real runtime process leaves the session running; a new runtime sees it, state intact', async () => {257    const env = makeTestEnv();258    const runtimeDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));259    const child = spawn(process.execPath, ['--import', 'tsx', path.join(runtimeDir, 'src', 'main.ts')], {260      cwd: runtimeDir,261      stdio: 'ignore',262      env: {263        ...process.env,264        CR_RUNTIME_PLUGINS: 'core',265        CR_DATA_DIR: env.config.dataDir,266        CR_RUNTIME_SOCKET: env.config.socketPath,267        CR_RUNTIME_TOKEN: env.config.token,268        CR_WORKSPACE_ROOTS: env.workspace,269        CR_TMUX_SOCKET: env.config.tmuxSocketName,270        CR_SHELL: '/bin/sh',271        CR_LOG_LEVEL: 'silent',272      },273    });274    let rt: Runtime | null = null;275    try {276      await waitUntil(() => fs.existsSync(env.config.socketPath), 20_000, 'child runtime socket');277      const sock = env.config.socketPath;278      const token = env.config.token;279      const id = (await request(sock, 'POST', '/sessions', { token, body: sessionSpec(env) })).json<RtSessionInfo>().id;280      const a = await TermClient.open(sock, id, token);281      a.type('KEPT=alive-$((20+3))\n');282      a.type('echo set-$KEPT\n');283      await a.waitOutput('set-alive-23');284285      child.kill('SIGKILL'); // no graceful shutdown at all286      await new Promise<void>((resolve) => child.once('exit', () => resolve()));287      await a.waitClose();288      expect(fs.existsSync(sock)).toBe(true); // stale socket left behind by the kill289290      rt = await startRuntime(env.config); // replaces the stale socket291      const s = (await request(sock, 'GET', `/sessions/${id}`, { token })).json<RtSessionInfo>();292      expect(s).toMatchObject({ id, alive: true, attached: 0 });293      const b = await TermClient.open(sock, id, token);294      b.type('echo again-$KEPT\n');295      await b.waitOutput('again-alive-23'); // same shell process as before the kill296      const events = await collectEvents(sock, token);297      b.type('exit 5\n');298      expect(await b.waitMsg('exit')).toEqual({ t: 'exit', code: 5 });299      await waitUntil(() => events.events.some((e) => e.type === 'session.exit' && e.id === id && e.exitCode === 5), 8000, 'session.exit after restart');300      events.close();301    } finally {302      child.kill('SIGKILL');303      await rt?.stop();304      env.cleanup();305    }306  });307});308
Files main
Menu

Theme