/** * test/tmux.test.ts (H1) — thin tmux CLI wrappers. * * execFileSync is mocked so no real tmux binary runs: every wrapper is * best-effort and must NOT throw (a throwing exec → false / no-op). */ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockExec = vi.fn() // promisify(execFile) reads the custom symbol, so give it one that returns our mock. const mockExecAsync = vi.fn() const fakeExecFile = Object.assign( (...a: unknown[]) => mockExecAsync(...a), { [Symbol.for('nodejs.util.promisify.custom')]: (...a: unknown[]) => mockExecAsync(...a) }, ) vi.mock('node:child_process', () => ({ execFileSync: (...a: unknown[]) => mockExec(...a), execFile: fakeExecFile, })) const { tmuxAvailable, tmuxName, hasSession, killSession, parseSessionList, listSessions, capturePane } = await import('../src/session/tmux.js') beforeEach(() => { mockExec.mockReset() mockExecAsync.mockReset() }) describe('tmuxName', () => { it('prefixes the session id with web_', () => { expect(tmuxName('abc')).toBe('web_abc') }) }) describe('tmuxAvailable', () => { it('returns true when `tmux -V` succeeds', () => { mockExec.mockReturnValue(Buffer.from('tmux 3.4')) expect(tmuxAvailable()).toBe(true) expect(mockExec).toHaveBeenCalledWith('tmux', ['-V'], expect.objectContaining({ stdio: 'ignore' })) }) it('returns false when tmux is missing (exec throws)', () => { mockExec.mockImplementation(() => { throw new Error('ENOENT') }) expect(tmuxAvailable()).toBe(false) }) }) describe('hasSession', () => { it('returns true when has-session exits 0', () => { mockExec.mockReturnValue(Buffer.from('')) expect(hasSession('web_x')).toBe(true) expect(mockExec).toHaveBeenCalledWith( 'tmux', ['has-session', '-t', 'web_x'], expect.objectContaining({ stdio: 'ignore' }), ) }) it('returns false when has-session throws (no such session)', () => { mockExec.mockImplementation(() => { throw new Error("can't find session") }) expect(hasSession('web_gone')).toBe(false) }) }) describe('killSession', () => { it('invokes tmux kill-session for the name', () => { mockExec.mockReturnValue(Buffer.from('')) killSession('web_x') expect(mockExec).toHaveBeenCalledWith( 'tmux', ['kill-session', '-t', 'web_x'], expect.objectContaining({ stdio: 'ignore' }), ) }) it('swallows errors when the session is already gone', () => { mockExec.mockImplementation(() => { throw new Error('no session') }) expect(() => killSession('web_gone')).not.toThrow() }) }) /* ── A: enumerate + preview tmux sessions the server does not track ─────────── */ const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870' const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b' describe('parseSessionList', () => { it('parses tmux rows into summaries, converting seconds to ms', () => { const out = `web_${U1}\t1783911991\t1783911992\t0\t80\t23\n` expect(parseSessionList(out)).toEqual([ { id: U1, createdAtMs: 1783911991_000, lastActivityAtMs: 1783911992_000, attached: false, cols: 80, rows: 23, }, ]) }) it('sorts most-recently-active first', () => { const out = [`web_${U1}\t100\t100\t0\t80\t24`, `web_${U2}\t100\t900\t1\t80\t24`].join('\n') expect(parseSessionList(out).map((s) => s.id)).toEqual([U2, U1]) }) it('reports a session attached from a real terminal', () => { const out = `web_${U1}\t100\t100\t1\t80\t24` expect(parseSessionList(out)[0]!.attached).toBe(true) }) it('ignores sessions that are not ours — never touch the user’s own tmux', () => { const out = ['mywork\t100\t100\t0\t80\t24', `web_${U1}\t100\t100\t0\t80\t24`].join('\n') expect(parseSessionList(out).map((s) => s.id)).toEqual([U1]) }) it('ignores a web_-prefixed name whose suffix is not a UUID v4', () => { // Only ids the protocol could have produced are actionable: anything else is // someone else's session that merely starts with web_. const out = ['web_../../etc\t100\t100\t0\t80\t24', 'web_-t\t100\t100\t0\t80\t24'].join('\n') expect(parseSessionList(out)).toEqual([]) }) it('skips malformed and blank rows instead of throwing', () => { const out = ['', 'garbage', `web_${U1}\tNaN\t100\t0\t80\t24`, `web_${U2}\t100\t100\t0\t80\t24`].join('\n') expect(parseSessionList(out).map((s) => s.id)).toEqual([U2]) }) it('returns an empty list for empty output (no tmux server)', () => { expect(parseSessionList('')).toEqual([]) }) }) describe('listSessions', () => { it('asks tmux for a machine-readable list', async () => { mockExecAsync.mockResolvedValue({ stdout: `web_${U1}\t100\t100\t0\t80\t24\n`, stderr: '' }) expect((await listSessions()).map((s) => s.id)).toEqual([U1]) const [file, args] = mockExecAsync.mock.calls[0] as [string, string[]] expect(file).toBe('tmux') expect(args[0]).toBe('list-sessions') expect(args).toContain('-F') }) it('asks for window_activity, NOT session_activity', async () => { // session_activity is a CLIENT clock: it never advances for a detached session // however much its shell prints. Using it would make idle-cleanup kill exactly // the unattended, actively-working sessions this feature exists to protect. mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) await listSessions() const [, args] = mockExecAsync.mock.calls[0] as [string, string[]] const fmt = args[args.indexOf('-F') + 1]! expect(fmt).toContain('#{window_activity}') expect(fmt).not.toContain('#{session_activity}') }) it('bounds the call so a wedged tmux cannot hang the server', async () => { mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) await listSessions() const opts = mockExecAsync.mock.calls[0]![2] as { timeout?: number; maxBuffer?: number } expect(opts.timeout).toBeGreaterThan(0) expect(opts.maxBuffer).toBeGreaterThan(0) }) it('returns [] when there is no tmux server (exec rejects)', async () => { mockExecAsync.mockRejectedValue(new Error('no server running')) expect(await listSessions()).toEqual([]) }) }) describe('capturePane', () => { it('captures the current screen WITHOUT attaching', async () => { mockExecAsync.mockResolvedValue({ stdout: 'hello\n', stderr: '' }) expect(await capturePane('web_x')).toBe('hello\n') const [file, args] = mockExecAsync.mock.calls[0] as [string, string[]] expect(file).toBe('tmux') expect(args[0]).toBe('capture-pane') expect(args).toContain('-p') // print to stdout — read-only, no client expect(args).toContain('-e') // keep escape sequences so colour survives expect(args).toContain('web_x') // Attaching would resize the session and SIGWINCH whatever is running in it. expect(args).not.toContain('attach-session') }) it('returns null when the session is gone', async () => { mockExecAsync.mockRejectedValue(new Error("can't find session")) expect(await capturePane('web_gone')).toBeNull() }) it('bounds the call', async () => { mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) await capturePane('web_x') const opts = mockExecAsync.mock.calls[0]![2] as { timeout?: number } expect(opts.timeout).toBeGreaterThan(0) }) })