Repository Public
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