Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/tmux/command.js
Original file line number Diff line number Diff line change
@@ -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,
};
83 changes: 83 additions & 0 deletions src/tmux/session.js
Original file line number Diff line number Diff line change
@@ -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,
};
68 changes: 68 additions & 0 deletions test/tmux-command.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
111 changes: 111 additions & 0 deletions test/tmux-session.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading