SPB Git

Repository Public

sessions.test.ts 14.6 KB 241 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import type { ServerEvent, TerminalSession } from '@cr/shared';3import { Client, createHarness, setupAdmin, until, type Harness } from '../../testkit/harness.js';45let h: Harness;6let admin: Client;7const events: ServerEvent[] = [];89const create = async (body: Record<string, unknown> = {}): Promise<TerminalSession> => {10  const res = await admin.post('/sessions', { kind: 'claude', ...body });11  expect(res.statusCode, res.body).toBe(201);12  return res.json<TerminalSession>();13};14const get = async (id: string): Promise<TerminalSession> => (await admin.get(`/sessions/${id}`)).json<TerminalSession>();15const history = async (id: string): Promise<string[]> =>16  (await h.server.ctx.db.q<{ detail: { from: string | null; to: string } }>("SELECT detail FROM terminal_events WHERE session_id = $1 AND kind = 'state' ORDER BY id", [id]))17    .map((r) => `${r.detail.from ?? '-'}>${r.detail.to}`);1819beforeAll(async () => {20  h = await createHarness();21  ({ client: admin } = await setupAdmin(h));22  const publish = h.server.ctx.hub.publish.bind(h.server.ctx.hub);23  h.server.ctx.hub.publish = (userId, ev) => {24    events.push(ev);25    publish(userId, ev);26  };27});28afterAll(async () => {29  await h.close();30});3132describe('create', () => {33  it('walks CREATED → STARTING → DETACHED, persists every transition and emits events', async () => {34    events.length = 0;35    const s = await create({ title: 'one' });36    expect(s).toMatchObject({ state: 'DETACHED', kind: 'claude', title: 'one', pid: 4242, cwd: '', attachedClients: 0, exitCode: null });37    expect(await history(s.id)).toEqual(['->CREATED', 'CREATED>STARTING', 'STARTING>DETACHED']);38    expect(events.filter((e) => e.type === 'session.updated').map((e) => (e.type === 'session.updated' ? e.session.state : ''))).toEqual(['STARTING', 'DETACHED']);39    const spec = h.rt.specs.get(s.id);40    expect(spec).toMatchObject({ id: s.id, kind: 'claude', cwd: h.workspace, args: [], cols: 120, rows: 32 });41    expect(JSON.stringify(s)).not.toContain(h.workspace); // absolute host paths never reach the browser42  });43  it('builds claude args server-side only; raw args from the browser are ignored or refused', async () => {44    const resumed = await create({ resumeConversationId: 'abcdef12-3456-7890' });45    expect(h.rt.specs.get(resumed.id)?.args).toEqual(['--resume', 'abcdef12-3456-7890']);46    expect(resumed.claudeConversationId).toBe('abcdef12-3456-7890');47    const cont = await create({ continueLast: true });48    expect(h.rt.specs.get(cont.id)?.args).toEqual(['--continue']);49    const sneaky = await create({ args: ['--dangerously-skip-permissions'], env: { X: '1' }, launch_args: ['-x'] });50    expect(h.rt.specs.get(sneaky.id)).toMatchObject({ args: [], env: {} });51    for (const bad of ['--dangerously-skip-permissions', 'x; rm -rf /', 'short', '-p-abcdefgh']) {52      expect((await admin.post('/sessions', { resumeConversationId: bad })).statusCode).toBe(400);53    }54    expect((await admin.post('/sessions', { kind: 'shell', continueLast: true })).statusCode).toBe(400);55    expect((await admin.post('/sessions', { cwd: '../outside' })).statusCode).toBe(400);56    expect((await admin.post('/sessions', { cwd: '/etc' })).statusCode).toBe(400);57    for (const spec of h.rt.specs.values()) expect(spec.args.join(' ')).not.toContain('dangerously');58  });59  it('a runtime that cannot start the session ⇒ FAILED, 503, nothing left live', async () => {60    await h.rt.stop();61    const res = await admin.post('/sessions', { title: 'down' });62    expect(res.statusCode).toBe(503);63    await h.rt.start();64    const row = await h.server.ctx.db.one<{ id: string; state: string }>("SELECT id, state FROM terminal_sessions WHERE title = 'down'");65    expect(row?.state).toBe('FAILED');66    expect(await history(row?.id as string)).toEqual(['->CREATED', 'CREATED>STARTING', 'STARTING>FAILED']);67  });68});6970describe('reconciler', () => {71  it('runtime unreachable ⇒ no state is guessed, health is reported OFFLINE', async () => {72    const s = await create();73    await h.rt.stop();74    expect(await h.server.reconcile()).toBe(false);75    expect((await get(s.id)).state).toBe('DETACHED');76    expect((await admin.post(`/sessions/${s.id}/reconnect`)).statusCode).toBe(503);77    expect((await admin.post(`/sessions/${s.id}/terminate`)).statusCode).toBe(503);78    expect((await get(s.id)).state).toBe('DETACHED');79    const status = (await admin.get('/system/status')).json<{ components: { component: string; health: string }[] }>();80    expect(status.components.find((c) => c.component === 'runtime')?.health).toBe('OFFLINE');81    expect(status.components.find((c) => c.component === 'tmux')?.health).toBe('UNKNOWN');82    expect(status.components.find((c) => c.component === 'database')?.health).toBe('ONLINE');83    await h.rt.start();84    expect(await h.server.reconcile()).toBe(true);85    expect((await get(s.id)).state).toBe('DETACHED');86  });87  it('healthy runtime without the session ⇒ RECOVERABLE; restart reuses id, cwd and resume args', async () => {88    const a = await create({ title: 'with-conv', resumeConversationId: 'conv-aaaa-1111' });89    const b = await create({ title: 'no-conv' });90    const sh = await create({ kind: 'shell', title: 'sh' });91    h.rt.sessions.clear(); // host reboot: the tmux server is gone92    h.rt.specs.clear();93    expect(await h.server.reconcile()).toBe(true);94    for (const s of [a, b, sh]) expect((await get(s.id)).state).toBe('RECOVERABLE');95    expect((await get(a.id)).pid).toBeNull();96    // a plain re-sync does not restart anything97    expect((await admin.post(`/sessions/${a.id}/reconnect`)).json().state).toBe('RECOVERABLE');98    expect(h.rt.sessions.size).toBe(0);99    const ra = (await admin.post(`/sessions/${a.id}/reconnect`, { restart: true })).json<TerminalSession>();100    expect(ra).toMatchObject({ id: a.id, state: 'DETACHED' });101    expect(h.rt.specs.get(a.id)).toMatchObject({ id: a.id, cwd: h.workspace, args: ['--resume', 'conv-aaaa-1111'] });102    await admin.post(`/sessions/${b.id}/reconnect`, { restart: true });103    expect(h.rt.specs.get(b.id)?.args).toEqual(['--continue']);104    await admin.post(`/sessions/${sh.id}/reconnect`, { restart: true });105    expect(h.rt.specs.get(sh.id)).toMatchObject({ kind: 'shell', args: [] });106    expect((await history(a.id)).slice(-3)).toEqual(['DETACHED>RECOVERABLE', 'RECOVERABLE>STARTING', 'STARTING>DETACHED']);107  });108  it('attached count drives RUNNING / DETACHED', async () => {109    const s = await create();110    (h.rt.sessions.get(s.id) as { attached: number }).attached = 2;111    await h.server.reconcile();112    expect(await get(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 2 });113    (h.rt.sessions.get(s.id) as { attached: number }).attached = 0;114    await h.server.reconcile();115    expect(await get(s.id)).toMatchObject({ state: 'DETACHED', attachedClients: 0 });116  });117  it('dead pane: exit 0 ⇒ STOPPED, non-zero ⇒ FAILED, with exit_code and a notification', async () => {118    const ok = await create({ title: 'ok' });119    const ko = await create({ title: 'ko' });120    Object.assign(h.rt.sessions.get(ok.id) as object, { alive: false, exitCode: 0 });121    Object.assign(h.rt.sessions.get(ko.id) as object, { alive: false, exitCode: 137 });122    await h.server.reconcile();123    expect(await get(ok.id)).toMatchObject({ state: 'STOPPED', exitCode: 0, pid: null });124    expect(await get(ko.id)).toMatchObject({ state: 'FAILED', exitCode: 137 });125    expect((await get(ko.id)).endedAt).not.toBeNull();126    const notes = (await admin.get('/notifications')).json<{ kind: string; title: string; body: string }[]>();127    expect(notes.filter((n) => n.kind === 'session.exited').length).toBeGreaterThanOrEqual(2);128    expect(notes.every((n) => n.body === '')).toBe(true); // generic by default: not even the title129    // terminal states are final130    expect(await h.server.ctx.sessions.transition(ok.id, 'RUNNING')).toBeNull();131    expect((await get(ok.id)).state).toBe('STOPPED');132    expect((await admin.post(`/sessions/${ok.id}/reconnect`, { restart: true })).json().state).toBe('STOPPED');133  });134});135136describe('runtime events', () => {137  beforeAll(async () => {138    h.server.startBackground({ timers: false });139    await until(() => h.rt.eventSubscribers === 1);140  });141  const waitState = (id: string, state: string): Promise<void> => until(async () => (await get(id)).state === state);142143  it('session.exit ⇒ STOPPED / FAILED', async () => {144    const a = await create();145    const b = await create();146    h.rt.emit({ type: 'session.exit', id: a.id, exitCode: 0 });147    h.rt.emit({ type: 'session.exit', id: b.id, exitCode: 2 });148    await waitState(a.id, 'STOPPED');149    await waitState(b.id, 'FAILED');150    expect((await get(b.id)).exitCode).toBe(2);151  });152  it('session.activity updates the attached count', async () => {153    const s = await create();154    h.rt.emit({ type: 'session.activity', id: s.id, atMs: Date.now(), attached: 1 });155    await waitState(s.id, 'RUNNING');156    h.rt.emit({ type: 'session.activity', id: s.id, atMs: Date.now(), attached: 0 });157    await waitState(s.id, 'DETACHED');158  });159  it('hooks: Notification/Stop ⇒ WAITING_FOR_INPUT (generic reason), UserPromptSubmit ⇒ RUNNING, conversation id stored', async () => {160    const s = await create({ title: 'secret project title' });161    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'SessionStart', conversationId: 'conv-9999-zzzz', message: null });162    await until(async () => (await get(s.id)).claudeConversationId === 'conv-9999-zzzz');163    expect((await get(s.id)).state).toBe('DETACHED');164    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'Notification', conversationId: null, message: 'Claude wants to run rm -rf build' });165    await waitState(s.id, 'WAITING_FOR_INPUT');166    const waiting = await get(s.id);167    expect(waiting.waitingReason).toBe('Claude needs your attention');168    expect(waiting.waitingReason).not.toContain('rm -rf');169    // attach counts do not override WAITING_FOR_INPUT170    (h.rt.sessions.get(s.id) as { attached: number }).attached = 1;171    await h.server.reconcile();172    expect((await get(s.id)).state).toBe('WAITING_FOR_INPUT');173    const waitingNote = async (): Promise<{ kind: string; body: string; link: string } | undefined> =>174      (await admin.get('/notifications')).json<{ kind: string; body: string; link: string }[]>().find((n) => n.link === `/terminal/?s=${s.id}` && n.kind === 'session.waiting');175    await until(async () => (await waitingNote()) !== undefined);176    expect(await waitingNote()).toMatchObject({ body: '' });177    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'UserPromptSubmit', conversationId: null, message: null });178    await waitState(s.id, 'RUNNING');179    expect((await get(s.id)).waitingReason).toBeNull();180    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'Stop', conversationId: null, message: null });181    await waitState(s.id, 'WAITING_FOR_INPUT');182    // a hook cannot smuggle an option-looking conversation id into a later --resume183    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'SessionStart', conversationId: '--dangerously-skip-permissions', message: null });184    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'UserPromptSubmit', conversationId: null, message: null });185    await waitState(s.id, 'RUNNING');186    expect((await get(s.id)).claudeConversationId).toBe('conv-9999-zzzz');187  });188  it('the hook message is shown only with notifications.detail = titles', async () => {189    const settings = (await admin.get('/settings')).json<{ notifications: Record<string, unknown> }>();190    await admin.req('PUT', '/settings', { ...settings, notifications: { ...settings.notifications, detail: 'titles' } });191    const s = await create({ title: 'titled' });192    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'Notification', conversationId: null, message: 'needs permission\u0007' });193    await waitState(s.id, 'WAITING_FOR_INPUT');194    expect((await get(s.id)).waitingReason).toBe('Claude needs your attention: needs permission ');195    const titledNote = async (): Promise<{ body: string } | undefined> =>196      (await admin.get('/notifications')).json<{ kind: string; body: string; link: string }[]>().find((n) => n.link === `/terminal/?s=${s.id}`);197    await until(async () => (await titledNote()) !== undefined);198    expect((await titledNote())?.body).toBe('titled');199    await admin.req('PUT', '/settings', settings);200  });201  it('hooks for ended or unknown sessions change nothing', async () => {202    const s = await create();203    await admin.post(`/sessions/${s.id}/terminate`);204    h.rt.emit({ type: 'claude.hook', id: s.id, event: 'Notification', conversationId: null, message: null });205    h.rt.emit({ type: 'claude.hook', id: '00000000-0000-4000-8000-000000000000', event: 'Stop', conversationId: null, message: null });206    await new Promise((r) => setTimeout(r, 150));207    expect((await get(s.id)).state).toBe('STOPPED');208  });209});210211describe('terminate / update / snapshot', () => {212  it('terminate: STOPPING → runtime DELETE → STOPPED', async () => {213    const s = await create();214    const out = (await admin.post(`/sessions/${s.id}/terminate`)).json<TerminalSession>();215    expect(out.state).toBe('STOPPED');216    expect(h.rt.sessions.has(s.id)).toBe(false);217    expect((await history(s.id)).slice(-2)).toEqual(['DETACHED>STOPPING', 'STOPPING>STOPPED']);218    expect((await admin.post(`/sessions/${s.id}/terminate`)).json().state).toBe('STOPPED'); // idempotent219  });220  it('rename and pin any time; archive only when not live', async () => {221    const s = await create();222    expect((await admin.req('PATCH', `/sessions/${s.id}`, { title: 'renamed', pinned: true })).json()).toMatchObject({ title: 'renamed', pinned: true });223    expect((await admin.req('PATCH', `/sessions/${s.id}`, { archived: true })).statusCode).toBe(409);224    expect((await admin.get(`/sessions/${s.id}/snapshot`)).json()).toEqual({ text: 'snapshot-text' });225    await admin.post(`/sessions/${s.id}/terminate`);226    expect((await admin.get(`/sessions/${s.id}/snapshot`)).statusCode).toBe(409);227    expect((await admin.req('PATCH', `/sessions/${s.id}`, { archived: true })).json().archived).toBe(true);228    const active = (await admin.get('/sessions')).json<TerminalSession[]>();229    expect(active.some((x) => x.id === s.id)).toBe(false);230    expect((await admin.get('/sessions?archived=1')).json<TerminalSession[]>().map((x) => x.id)).toEqual([s.id]);231  });232  it('markup in titles round-trips as inert JSON', async () => {233    const title = '<img src=x onerror=alert(1)>"\'</script>';234    const s = await create({ title });235    const res = await admin.get(`/sessions/${s.id}`);236    expect(res.headers['content-type']).toContain('application/json');237    expect(res.json().title).toBe(title);238    expect((await admin.get('/nope/<script>alert(1)</script>')).headers['content-type']).toContain('application/json');239  });240});241
Files main
Menu

Theme