Files
arboretum/packages/server/test/pty-manager.test.ts
Johan LEROY 664e2b583c P4-B: notifications Web Push (VAPID) sur passage en waiting
- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)
2026-06-15 12:13:56 +02:00

578 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import type { PushPayload, PushService } from '../src/core/push-service.js';
import { openDb, type Db } from '../src/db/index.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
interface FakePty {
pid: number;
file: string;
args: string[];
opts: { cwd: string; cols: number; rows: number };
write: Mock;
resize: Mock;
pause: Mock;
resume: Mock;
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
let nextPid = 100_000;
class FakePtyImpl {
pid = nextPid++;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
private dataCbs: Array<(d: string) => void> = [];
private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
constructor(
readonly file: string,
readonly args: string[],
readonly opts: unknown,
) {}
onData(cb: (d: string) => void): { dispose: () => void } {
this.dataCbs.push(cb);
return { dispose: () => {} };
}
onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
this.exitCbs.push(cb);
return { dispose: () => {} };
}
emitData(d: string): void {
for (const cb of this.dataCbs) cb(d);
}
emitExit(exitCode: number, signal?: number): void {
for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
}
}
return {
default: {
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
const p = new FakePtyImpl(file, args, opts);
ptyMock.instances.push(p);
return p;
},
},
};
});
type BindingSpies = ClientBinding & {
sendOutput: Mock<(payload: Buffer) => void>;
sendResync: Mock<(payload: Buffer) => void>;
onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
onControlChanged: Mock<(controlling: boolean) => void>;
};
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
return {
channel: channelSeq++,
mode,
controlling: false,
sentBytes: 0,
ackedBytes: 0,
lagging: false,
sendOutput: vi.fn(),
sendResync: vi.fn(),
onDetached: vi.fn(),
onControlChanged: vi.fn(),
};
}
function lastPty(): FakePty {
return ptyMock.instances.at(-1) as FakePty;
}
describe('PtyManager (pty mocké)', () => {
let db: Db;
let manager: PtyManager;
const cwd = tmpdir();
beforeEach(() => {
ptyMock.instances.length = 0;
db = openDb(':memory:');
manager = new PtyManager(db);
});
function spawnBash(): { summary: SessionSummary; pty: FakePty } {
const summary = manager.spawn({ cwd, command: 'bash' });
return { summary, pty: lastPty() };
}
describe('spawn', () => {
it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
const updates: SessionSummary[] = [];
manager.on('session_update', (s) => updates.push(s));
const { summary, pty } = spawnBash();
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual(summary);
expect(summary.status).toBe('running');
expect(summary.live).toBe(true);
expect(summary.clients).toBe(0);
expect(summary.command).toBe('bash');
expect(pty.file).toBe('bash');
expect(pty.args).toEqual(['--norc']);
expect(pty.opts.cwd).toBe(cwd);
expect(pty.opts.cols).toBe(120);
expect(pty.opts.rows).toBe(32);
const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
expect(row).toEqual({ cwd, command: 'bash' });
expect(manager.get(summary.id)?.id).toBe(summary.id);
});
it('cwd inexistant → erreur statusCode 400, rien en base', () => {
expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
expect.objectContaining({ statusCode: 400 }),
);
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
expect(row.n).toBe(0);
});
it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
const { summary } = spawnBash();
expect(summary).toMatchObject({
source: 'managed',
attachable: true,
resumable: false,
registryStatus: null,
claudeSessionId: null,
});
expect(summary.pid).toBeGreaterThan(0);
});
});
describe('resume / fork (P2)', () => {
it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
const p = lastPty();
expect(summary.command).toBe('claude');
expect(summary.source).toBe('managed');
expect(summary.attachable).toBe(true);
expect(p.file).toBe('/usr/bin/claude');
expect(p.args).toEqual(['--resume', 'abc-123']);
const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
resumed_from: string | null;
command: string;
};
expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
});
it('fork → --resume <id> --fork-session', () => {
manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
});
it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
// simule la capture du claudeSessionId (normalement résolue via le registre)
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
lastPty().emitExit(0);
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
});
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
vi.useFakeTimers();
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
try {
const m = new PtyManager(db, sessDir);
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
vi.advanceTimersByTime(400);
const found = m.findLiveByClaudeSessionId('captured-sid');
expect(found?.id).toBe(summary.id);
expect(found?.claudeSessionId).toBe('captured-sid');
} finally {
vi.useRealTimers();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('attach', () => {
it('session inconnue → NOT_FOUND', () => {
const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
});
it('resync à lattach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
const { summary, pty } = spawnBash();
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
for (const c of chunks) pty.emitData(c);
const b = makeBinding('interactive');
const res = manager.attach(summary.id, b, 80, 24);
expect(res).toEqual({ ok: true, controlling: true });
expect(b.sendResync).toHaveBeenCalledTimes(1);
const payload = b.sendResync.mock.calls[0]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
// compteurs de flow control remis à zéro après le resync initial
expect(b.sentBytes).toBe(0);
expect(b.ackedBytes).toBe(0);
});
it('ring vide → resync avec payload vide', () => {
const { summary } = spawnBash();
const b = makeBinding('interactive');
manager.attach(summary.id, b, 80, 24);
expect(b.sendResync).toHaveBeenCalledTimes(1);
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
});
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
const a = makeBinding('interactive');
const b = makeBinding('interactive');
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
expect(manager.get(summary.id)?.clients).toBe(3);
});
});
describe('write / resize', () => {
it('observer read-only : write refusé', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
expect(pty.write).not.toHaveBeenCalled();
});
it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
expect(pty.write).toHaveBeenCalledTimes(2);
expect(manager.write('nope', a, 'x')).toBe('gone');
});
it('resize ignoré pour les non-contrôleurs', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
pty.resize.mockClear();
manager.resize(summary.id, b, 50, 20);
expect(pty.resize).not.toHaveBeenCalled();
manager.resize(summary.id, a, 90, 28);
expect(pty.resize).toHaveBeenCalledWith(90, 28);
});
});
describe('answer (P4-A)', () => {
it('select → "N\\r" si loption existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-'));
const m = new PtyManager(db, sessDir);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
// écran : dialogue de permission à 2 options (la 1re est sélectionnée )
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\nEsc to cancel\r\n');
writeFileSync(
join(sessDir, `${p.pid}.json`),
JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }),
);
await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir létat
const a = makeBinding('interactive');
m.attach(summary.id, a, 80, 24);
p.write.mockClear();
// option inexistante → invalid (anti-frappe fantôme), aucun write
expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid');
expect(p.write).not.toHaveBeenCalled();
// option existante → "1\r"
expect(m.answer(summary.id, a, 'select', 1)).toBe('ok');
expect(p.write).toHaveBeenCalledWith('1\r');
// deny → Esc
expect(m.answer(summary.id, a, 'deny')).toBe('ok');
expect(p.write).toHaveBeenCalledWith('\x1b');
// observer read-only
const obs = makeBinding('observer');
m.attach(summary.id, obs, 80, 24);
expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling');
// session inconnue → gone
expect(m.answer('nope', a, 'deny')).toBe('gone');
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
it('session sans état waiting (bash, pas de tracker) → invalid', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.write.mockClear();
expect(manager.answer(summary.id, a, 'deny')).toBe('invalid');
expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid');
expect(pty.write).not.toHaveBeenCalled();
});
});
describe('push trigger (P4-B)', () => {
it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-'));
const notifies: PushPayload[] = [];
const fakePush = {
notify: async (p: PushPayload) => {
notifies.push(p);
},
} as unknown as PushService;
const m = new PtyManager(db, sessDir, fakePush);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
const regFile = join(sessDir, `${p.pid}.json`);
const writeReg = (status: string, waitingFor?: string): void =>
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) }));
// busy : front montant vers busy, pas de notif
writeReg('busy');
p.emitData('working…');
await sleep(260);
expect(notifies).toHaveLength(0);
// waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms)
writeReg('waiting', 'permission prompt');
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\n');
await sleep(260);
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
await sleep(1500); // dépasse le debounce
expect(notifies).toHaveLength(1);
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` });
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
expect(pty.pause).not.toHaveBeenCalled();
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas
pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).toHaveBeenCalledTimes(1);
expect(pty.resume).not.toHaveBeenCalled();
manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
expect(pty.resume).toHaveBeenCalledTimes(1);
expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
});
it('les observers ne freinent jamais le PTY', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const chunk = 'y'.repeat(200 * 1024);
for (let i = 0; i < 3; i++) {
pty.emitData(chunk);
manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
}
expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).not.toHaveBeenCalled();
});
it('session sans interactif (observers seuls) : jamais de pause', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
pty.emitData('z'.repeat(500 * 1024));
expect(pty.pause).not.toHaveBeenCalled();
});
});
describe('client lagging', () => {
it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
const mib = 1024 * 1024;
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
pty.emitData(chunks[0]!); // outstanding 1 Mio
pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
expect(a.lagging).toBe(false);
expect(a.sendOutput).toHaveBeenCalledTimes(2);
pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
expect(a.lagging).toBe(true);
expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
// le seul interactif est lagging : le PTY ne doit pas rester en pause
expect(pty.resume).toHaveBeenCalled();
pty.emitData(chunks[3]!); // flux coupé pour le client lagging
expect(a.sendOutput).toHaveBeenCalledTimes(3);
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
expect(a.lagging).toBe(false);
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
const payload = a.sendResync.mock.calls[1]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
expect(a.sentBytes).toBe(0);
expect(a.ackedBytes).toBe(0);
// le flux reprend normalement après le resync
pty.emitData('tail');
expect(a.sendOutput).toHaveBeenCalledTimes(4);
expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
});
});
describe('detach et transfert de contrôle', () => {
it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(a.controlling).toBe(true);
expect(b.controlling).toBe(false);
manager.detach(summary.id, a);
expect(b.controlling).toBe(true);
expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
expect(obs.onControlChanged).not.toHaveBeenCalled();
expect(manager.get(summary.id)?.clients).toBe(2);
});
it('detach dun non-contrôleur : aucun transfert', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
manager.detach(summary.id, b);
expect(a.controlling).toBe(true);
expect(a.onControlChanged).not.toHaveBeenCalled();
});
it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.detach(summary.id, a);
expect(obs.controlling).toBe(false);
expect(obs.onControlChanged).not.toHaveBeenCalled();
});
it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.detach(summary.id, a);
const b = makeBinding('interactive');
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
});
});
describe('exit', () => {
it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
const updates: SessionSummary[] = [];
manager.on('session_exit', (e) => exits.push(e));
manager.on('session_update', (s) => updates.push(s));
pty.emitExit(0);
expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });
const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
expect(row.ended_at).not.toBeNull();
expect(row.exit_code).toBe(0);
expect(manager.get(summary.id)).toBeNull();
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
});
it('exit avec signal : propagé dans session_exit', () => {
const { summary, pty } = spawnBash();
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
manager.on('session_exit', (e) => exits.push(e));
pty.emitExit(1, 15);
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
});
it('après exit : write → gone, attach → NOT_FOUND', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.emitExit(0);
expect(manager.write(summary.id, a, 'x')).toBe('gone');
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
});
});
});