sotashimozono_obsidian-remo.../plugin/tests/RemoteShell.test.ts
sotashimozono e44c476c68
feat: integrated remote terminal pane (xterm.js + ssh2 shell channel) (#149) (#234)
Closes #149. Adds a single-pane remote terminal in the right sidebar,
backed by xterm.js and an ssh2 PTY channel multiplexed onto the
already-authenticated SSH connection that SftpClient owns.

## Files

- `src/ssh/SftpClient.ts` — `openShell({rows, cols, term?, cmd?})`
  method (mirrors existing `openUnixStream` / `exec` shape). When `cmd`
  is supplied, runs it under the PTY (e.g. `/usr/bin/zsh -l`); otherwise
  the remote launches the user's default login shell.
- `src/ssh/RemoteShell.ts` (NEW) — thin lifecycle wrapper around the
  ssh2 ClientChannel. `open() / write() / resize() / close() / isOpen()`.
  Stdout + stderr both surface as a single `onData(chunk)`. Two
  synchronous-flag guards keep `open()` race-free:
  `opening` blocks concurrent open() calls before they reach the
  await; the post-await `if (this.closed) ch.end()` check prevents a
  channel leak when close() runs while open() is pending.
- `src/ui/RemoteTerminalView.ts` (NEW) — `extends ItemView`, renders
  xterm.js + FitAddon, debounced 100 ms ResizeObserver → setWindow,
  graceful "Not connected" / "Failed to open shell" disconnected
  states. Reads font/scrollback/shell from plugin settings each `onOpen`.
  The `onClose` callback logs `reason`/`cause` unconditionally so the
  channel-close audit trail isn't lost when the View has torn down.
  The `shell.open()` catch block disposes + nulls shell/term/fit so
  late channel events can't reach a half-constructed view.
- `src/main.ts` — `registerView(VIEW_TYPE_REMOTE_TERMINAL, ...)` plus
  `addCommand("Open remote terminal")` (checkCallback gates on
  client.isAlive). `disconnect()` calls
  `app.workspace.detachLeavesOfType(VIEW_TYPE_REMOTE_TERMINAL)` so the
  shell channel close fires while ssh2.Client is still around.
  `openRemoteTerminal()` uses `setActiveLeaf` rather than `revealLeaf`
  to keep the minAppVersion 1.4.0 contract (revealLeaf needs 1.7.2).
  `openingTerminal` re-entrant flag prevents two leaves being created
  by rapid command-palette activations.
- `src/types.ts` + `src/constants.ts` — three new optional settings:
  `terminalShell?: string`, `terminalFontSize?: number` (default 12),
  `terminalScrollback?: number` (default 1000).
- `src/settings/SettingsTab.ts` — Terminal section with the three
  inputs (font validated 6-32 px, scrollback 100-100_000 lines).
- `styles.css` — terminal pane host/disconnected styling + inlined
  xterm.js v5 default stylesheet (~3 KB, MIT license preserved) so
  users don't need a separate CSS asset.
- `package.json` — `@xterm/xterm@^5.5` + `@xterm/addon-fit@^0.10`
  added as production dependencies.
- `.github/workflows/release.yml` + `.github/workflows/ci.yml` —
  bundle cap raised 600 -> 800 KB to absorb the bundled xterm
  (770 KB actual). esbuild splitting needs ESM which Obsidian doesn't
  support, so single-bundle is the only option. Comment in workflows
  explains the rationale.
- `tests/RemoteShell.test.ts` (NEW) — 20 unit tests against a fake
  ssh2 ClientChannel: open/double-open guards, concurrent-open guard,
  close-while-pending end()s the channel + bypasses listener-attach,
  stdout+stderr routing, write/resize/close, lifecycle invariants
  (no double-onClose, suppressed-after-close events, etc.).
- `vitest.config.ts` — `RemoteTerminalView.ts` added to coverage
  exclude (xterm.js + ResizeObserver under jsdom is impractical to
  unit-test; covered by manual smoke against a real Obsidian window,
  consistent with the other src/ui/*Modal/Bar exclusions).

## Verified

- `tsc --noEmit` clean
- `npm run lint` clean (sentence-case + active-window-timers
  + no-floating-promises + minAppVersion gates all satisfied)
- `vitest run` 780 pass / 1 Windows-skipped (was 760)
- `node esbuild.config.mjs production` -> 770 KB main.js (under 800 KB cap)

## Out of scope (per #149 v1)

- Multiple terminal tabs
- Persisted scrollback across re-opens
- Split panes
- Search-in-scrollback
- back-pressure on huge pastes (ssh2 handles it; we trust write() return)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:17:11 +09:00

263 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';
import { RemoteShell } from '../src/ssh/RemoteShell';
import type { SftpClient } from '../src/ssh/SftpClient';
import type { ClientChannel } from 'ssh2';
/**
* Minimal stand-in for an ssh2 ClientChannel:
* - `data`/`error`/`close` events surface via the EventEmitter
* - `stderr` is its own EventEmitter (matches ssh2's shape)
* - `write` / `setWindow` / `end` are spy functions so tests can assert call shape
*/
function makeFakeChannel() {
const ch = new EventEmitter() as EventEmitter & Partial<ClientChannel> & {
stderr: EventEmitter;
write: ReturnType<typeof vi.fn>;
setWindow: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
ch.stderr = new EventEmitter();
ch.write = vi.fn();
ch.setWindow = vi.fn();
ch.end = vi.fn(() => { ch.emit('close'); });
return ch;
}
function makeFakeClient(channel: ReturnType<typeof makeFakeChannel>) {
return {
openShell: vi.fn().mockResolvedValue(channel as unknown as ClientChannel),
} as unknown as SftpClient;
}
describe('RemoteShell', () => {
let onData: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
beforeEach(() => {
onData = vi.fn();
onClose = vi.fn();
});
describe('open()', () => {
it('forwards row/col/term/cmd to SftpClient.openShell', async () => {
const ch = makeFakeChannel();
const client = makeFakeClient(ch);
const shell = new RemoteShell(client, { onData, onClose });
await shell.open({ rows: 30, cols: 100, term: 'xterm', cmd: '/usr/bin/zsh -l' });
expect(client.openShell).toHaveBeenCalledWith({
rows: 30, cols: 100, term: 'xterm', cmd: '/usr/bin/zsh -l',
});
expect(shell.isOpen()).toBe(true);
});
it('rejects when called twice without close in between', async () => {
const ch = makeFakeChannel();
const client = makeFakeClient(ch);
const shell = new RemoteShell(client, { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
await expect(shell.open({ rows: 24, cols: 80 })).rejects.toThrow(/already open/);
});
it('rejects when called after close', async () => {
const ch = makeFakeChannel();
const client = makeFakeClient(ch);
const shell = new RemoteShell(client, { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
shell.close();
await expect(shell.open({ rows: 24, cols: 80 })).rejects.toThrow(/already closed/);
});
// Regression: HIGH-1 from PR #234 self-review. Two concurrent
// open() callers used to both pass the `if (this.channel)` guard
// because it ran BEFORE the await — both would allocate channels
// server-side, only the second `this.channel = ch` survived, the
// first leaked. The synchronous `opening` flag closes that gap.
it('rejects a second open() called concurrently while the first is still pending', async () => {
const ch = makeFakeChannel();
const client = makeFakeClient(ch);
let resolveFirst!: (c: ClientChannel) => void;
(client.openShell as ReturnType<typeof vi.fn>).mockImplementationOnce(
() => new Promise<ClientChannel>(r => { resolveFirst = r; }),
);
const shell = new RemoteShell(client, { onData, onClose });
const firstOpen = shell.open({ rows: 24, cols: 80 });
// Yield once so the synchronous portion of `open()` has run
// (the `this.opening = true` assignment + the await).
await Promise.resolve();
await expect(shell.open({ rows: 24, cols: 80 })).rejects.toThrow(/already opening/);
// Let the first open complete cleanly so the test doesn't dangle.
resolveFirst(ch as unknown as ClientChannel);
await firstOpen;
expect(shell.isOpen()).toBe(true);
});
// Regression: HIGH-2 from PR #234 self-review. If close() ran
// while open()'s await was pending, `this.closed` was set but the
// channel still got assigned + listeners attached when the await
// resolved — and nobody ever called end() on it. Channel leak.
it('end()s the channel if close() runs while open() is awaiting', async () => {
const ch = makeFakeChannel();
const client = makeFakeClient(ch);
let resolveOpen!: (c: ClientChannel) => void;
(client.openShell as ReturnType<typeof vi.fn>).mockImplementationOnce(
() => new Promise<ClientChannel>(r => { resolveOpen = r; }),
);
const shell = new RemoteShell(client, { onData, onClose });
const openPromise = shell.open({ rows: 24, cols: 80 });
await Promise.resolve();
shell.close();
resolveOpen(ch as unknown as ClientChannel);
await openPromise;
expect(ch.end).toHaveBeenCalledTimes(1);
expect(shell.isOpen()).toBe(false);
// Listeners must NOT have been attached: post-resolve `data`
// events should not call onData. (close() set `this.closed`
// before the channel was assigned, so the listener-attach
// branch was bypassed entirely.)
ch.emit('data', Buffer.from('post-close', 'utf8'));
expect(onData).not.toHaveBeenCalled();
});
});
describe('data flow', () => {
it('forwards stdout `data` events to onData', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
const chunk = Buffer.from('hello\r\n', 'utf8');
ch.emit('data', chunk);
expect(onData).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenCalledWith(chunk);
});
it('forwards stderr `data` events to the same onData callback', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
const errChunk = Buffer.from('command not found\n', 'utf8');
ch.stderr.emit('data', errChunk);
expect(onData).toHaveBeenCalledWith(errChunk);
});
it('write() pipes through to channel.write', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
shell.write('ls -la\r');
expect(ch.write).toHaveBeenCalledWith('ls -la\r');
});
it('write() is a no-op if channel was never opened', () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
shell.write('lost keystroke');
expect(ch.write).not.toHaveBeenCalled();
});
it('write() is a no-op after close', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
shell.close();
shell.write('post-close');
expect(ch.write).not.toHaveBeenCalled();
});
});
describe('resize()', () => {
it('forwards rows/cols to channel.setWindow with 0 pixel dims', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
shell.resize(40, 120);
expect(ch.setWindow).toHaveBeenCalledWith(40, 120, 0, 0);
});
it('survives a setWindow throw without crashing the wrapper', async () => {
const ch = makeFakeChannel();
ch.setWindow = vi.fn(() => { throw new Error('channel gone'); });
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
expect(() => shell.resize(40, 120)).not.toThrow();
expect(shell.isOpen()).toBe(true);
});
});
describe('close lifecycle', () => {
it('remote-side close fires onClose with reason "remote-eof"', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
ch.emit('close');
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith('remote-eof');
expect(shell.isOpen()).toBe(false);
});
it('channel error fires onClose with reason "error" + cause', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
const err = new Error('connection reset');
ch.emit('error', err);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith('error', err);
expect(shell.isOpen()).toBe(false);
});
it('local close() does NOT re-fire onClose after remote close', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
ch.emit('close');
shell.close();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('local close() suppresses subsequent data events', async () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
await shell.open({ rows: 24, cols: 80 });
shell.close();
ch.emit('data', Buffer.from('late', 'utf8'));
ch.stderr.emit('data', Buffer.from('also late', 'utf8'));
expect(onData).not.toHaveBeenCalled();
});
it('close() before open() is a no-op (idempotent)', () => {
const ch = makeFakeChannel();
const shell = new RemoteShell(makeFakeClient(ch), { onData, onClose });
expect(() => shell.close()).not.toThrow();
expect(onClose).not.toHaveBeenCalled();
expect(shell.isOpen()).toBe(false);
});
});
});