diff --git a/src/tmux/command.js b/src/tmux/command.js new file mode 100644 index 00000000..f4d50c54 --- /dev/null +++ b/src/tmux/command.js @@ -0,0 +1,27 @@ +const runtime = require('../core/runtime'); + +function assertArgs(args) { + if (!Array.isArray(args)) { + throw new TypeError('tmux args must be an array'); + } + for (const arg of args) { + if (typeof arg !== 'string') { + throw new TypeError('tmux args must contain only strings'); + } + } +} + +function runTmux(args, options = {}) { + assertArgs(args); + return runtime.run('tmux', args, options); +} + +function isTmuxAvailable() { + const result = runTmux(['-V'], { stdio: 'pipe' }); + return result.status === 0 && !result.error; +} + +module.exports = { + isTmuxAvailable, + runTmux, +}; diff --git a/src/tmux/session.js b/src/tmux/session.js new file mode 100644 index 00000000..60c3d3de --- /dev/null +++ b/src/tmux/session.js @@ -0,0 +1,83 @@ +const tmux = require('./command'); + +function requireName(name) { + if (typeof name !== 'string' || name.trim() === '') { + throw new TypeError('tmux session name must be a non-empty string'); + } + return name; +} + +function addCwd(args, cwd) { + if (cwd !== undefined) { + if (typeof cwd !== 'string' || cwd.trim() === '') { + throw new TypeError('tmux cwd must be a non-empty string'); + } + args.push('-c', cwd); + } +} + +function sessionExists(name) { + const result = tmux.runTmux(['has-session', '-t', requireName(name)], { + stdio: 'pipe', + }); + return result.status === 0; +} + +function createSession(name, cwd) { + const args = ['new-session', '-d', '-s', requireName(name)]; + addCwd(args, cwd); + return tmux.runTmux(args); +} + +function attachSession(name) { + return tmux.runTmux(['attach-session', '-t', requireName(name)], { + stdio: 'inherit', + }); +} + +function newWindowOrPane(options = {}) { + const { + target, + cwd, + name, + pane = false, + split = 'vertical', + } = options; + const args = pane ? ['split-window'] : ['new-window']; + + if (pane) { + if (split === 'horizontal') { + args.push('-h'); + } else if (split === 'vertical') { + args.push('-v'); + } else { + throw new TypeError('tmux split must be horizontal or vertical'); + } + } + if (target !== undefined) { + args.push('-t', requireName(target)); + } + if (!pane && name !== undefined) { + args.push('-n', requireName(name)); + } + addCwd(args, cwd); + return tmux.runTmux(args); +} + +function sendKeys(paneId, command) { + if (typeof paneId !== 'string' || paneId.trim() === '') { + throw new TypeError('tmux pane id must be a non-empty string'); + } + if (typeof command !== 'string') { + throw new TypeError('tmux command must be a string'); + } + return tmux.runTmux(['send-keys', '-t', paneId, command, 'C-m']); +} + +module.exports = { + sessionExists, + createSession, + attachSession, + newWindowOrPane, + sendKeys, +}; diff --git a/test/tmux-command.test.js b/test/tmux-command.test.js new file mode 100644 index 00000000..a3890c0b --- /dev/null +++ b/test/tmux-command.test.js @@ -0,0 +1,68 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const runtime = require('../src/core/runtime'); +const tmuxCommand = require('../src/tmux/command'); + +test('runTmux delegates to runtime command helper with argv array', () => { + const calls = []; + const originalRun = runtime.run; + runtime.run = (cmd, args, options) => { + calls.push({ cmd, args, options }); + return { status: 0, stdout: 'ok\n', stderr: '' }; + }; + + try { + const result = tmuxCommand.runTmux(['display-message', '-p', '#S'], { + cwd: '/tmp/project', + }); + + assert.equal(result.status, 0); + assert.deepEqual(calls, [ + { + cmd: 'tmux', + args: ['display-message', '-p', '#S'], + options: { cwd: '/tmp/project' }, + }, + ]); + } finally { + runtime.run = originalRun; + } +}); + +test('runTmux rejects non-array args', () => { + assert.throws(() => tmuxCommand.runTmux('tmux -V'), /args must be an array/); +}); + +test('runTmux rejects non-string args', () => { + assert.throws(() => tmuxCommand.runTmux(['new-session', 7]), /only strings/); +}); + +test('isTmuxAvailable probes tmux version', () => { + const calls = []; + const originalRun = runtime.run; + runtime.run = (cmd, args, options) => { + calls.push({ cmd, args, options }); + return { status: 0, stdout: 'tmux 3.4\n', stderr: '' }; + }; + + try { + assert.equal(tmuxCommand.isTmuxAvailable(), true); + assert.deepEqual(calls, [ + { cmd: 'tmux', args: ['-V'], options: { stdio: 'pipe' } }, + ]); + } finally { + runtime.run = originalRun; + } +}); + +test('isTmuxAvailable returns false when probe fails', () => { + const originalRun = runtime.run; + runtime.run = () => ({ status: 127, error: new Error('missing tmux') }); + + try { + assert.equal(tmuxCommand.isTmuxAvailable(), false); + } finally { + runtime.run = originalRun; + } +}); diff --git a/test/tmux-session.test.js b/test/tmux-session.test.js new file mode 100644 index 00000000..74576084 --- /dev/null +++ b/test/tmux-session.test.js @@ -0,0 +1,111 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const tmuxCommand = require('../src/tmux/command'); +const tmuxSession = require('../src/tmux/session'); + +function withMockedTmux(callback) { + const calls = []; + const originalRunTmux = tmuxCommand.runTmux; + tmuxCommand.runTmux = (args, options) => { + calls.push({ args, options }); + return { status: 0, stdout: '', stderr: '' }; + }; + + try { + callback(calls); + } finally { + tmuxCommand.runTmux = originalRunTmux; + } +} + +test('sessionExists checks target session without shell command strings', () => { + withMockedTmux((calls) => { + assert.equal(tmuxSession.sessionExists('gx-cockpit'), true); + assert.deepEqual(calls, [ + { + args: ['has-session', '-t', 'gx-cockpit'], + options: { stdio: 'pipe' }, + }, + ]); + }); +}); + +test('createSession builds detached session argv with cwd', () => { + withMockedTmux((calls) => { + tmuxSession.createSession('gx-cockpit', '/repo'); + assert.deepEqual(calls, [ + { + args: ['new-session', '-d', '-s', 'gx-cockpit', '-c', '/repo'], + options: undefined, + }, + ]); + }); +}); + +test('attachSession attaches with inherited stdio', () => { + withMockedTmux((calls) => { + tmuxSession.attachSession('gx-cockpit'); + assert.deepEqual(calls, [ + { + args: ['attach-session', '-t', 'gx-cockpit'], + options: { stdio: 'inherit' }, + }, + ]); + }); +}); + +test('newWindowOrPane creates named window argv', () => { + withMockedTmux((calls) => { + tmuxSession.newWindowOrPane({ + target: 'gx-cockpit', + name: 'status', + cwd: '/repo', + }); + assert.deepEqual(calls, [ + { + args: ['new-window', '-t', 'gx-cockpit', '-n', 'status', '-c', '/repo'], + options: undefined, + }, + ]); + }); +}); + +test('newWindowOrPane creates split pane argv', () => { + withMockedTmux((calls) => { + tmuxSession.newWindowOrPane({ + pane: true, + split: 'horizontal', + target: 'gx-cockpit:0', + cwd: '/repo', + }); + assert.deepEqual(calls, [ + { + args: ['split-window', '-h', '-t', 'gx-cockpit:0', '-c', '/repo'], + options: undefined, + }, + ]); + }); +}); + +test('sendKeys targets pane and sends command as one argv value', () => { + withMockedTmux((calls) => { + tmuxSession.sendKeys('%1', 'gx status; echo still-literal'); + assert.deepEqual(calls, [ + { + args: ['send-keys', '-t', '%1', 'gx status; echo still-literal', 'C-m'], + options: undefined, + }, + ]); + }); +}); + +test('session helpers reject empty target values', () => { + assert.throws(() => tmuxSession.sessionExists(''), /non-empty string/); + assert.throws(() => tmuxSession.createSession('gx', ''), /cwd/); + assert.throws( + () => tmuxSession.newWindowOrPane({ pane: true, split: 'diagonal' }), + /horizontal or vertical/, + ); + assert.throws(() => tmuxSession.sendKeys('', 'gx status'), /pane id/); +});