Add ESLint v9.35+ with typescript-eslint recommended config and
error-handling rules: preserve-caught-error (enforces { cause } when
re-throwing), no-unused-vars with caughtErrors:all, and
eslint-plugin-no-catch-all (warns on catch blocks that don't rethrow).
Fix existing violations: add error cause to container-runtime rethrow,
prefix unused vars with underscore, remove unused imports.
https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import fs from 'fs';
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
// Mock config before importing the module under test
|
|
vi.mock('./config.js', () => ({
|
|
DATA_DIR: '/tmp/nanoclaw-rc-test',
|
|
}));
|
|
|
|
// Mock child_process
|
|
const spawnMock = vi.fn();
|
|
vi.mock('child_process', () => ({
|
|
spawn: (...args: any[]) => spawnMock(...args),
|
|
}));
|
|
|
|
import {
|
|
startRemoteControl,
|
|
stopRemoteControl,
|
|
restoreRemoteControl,
|
|
getActiveSession,
|
|
_resetForTesting,
|
|
_getStateFilePath,
|
|
} from './remote-control.js';
|
|
|
|
// --- Helpers ---
|
|
|
|
function createMockProcess(pid = 12345) {
|
|
return {
|
|
pid,
|
|
unref: vi.fn(),
|
|
kill: vi.fn(),
|
|
stdin: { write: vi.fn(), end: vi.fn() },
|
|
};
|
|
}
|
|
|
|
describe('remote-control', () => {
|
|
const STATE_FILE = _getStateFilePath();
|
|
let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
let _mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
let openSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
// Track what readFileSync should return for the stdout file
|
|
let stdoutFileContent: string;
|
|
|
|
beforeEach(() => {
|
|
_resetForTesting();
|
|
spawnMock.mockReset();
|
|
stdoutFileContent = '';
|
|
|
|
// Default fs mocks
|
|
_mkdirSyncSpy = vi
|
|
.spyOn(fs, 'mkdirSync')
|
|
.mockImplementation(() => undefined as any);
|
|
writeFileSyncSpy = vi
|
|
.spyOn(fs, 'writeFileSync')
|
|
.mockImplementation(() => {});
|
|
unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
|
openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
|
|
closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
|
|
|
|
// readFileSync: return stdoutFileContent for the stdout file, state file, etc.
|
|
readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
|
|
p: string,
|
|
) => {
|
|
if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
|
|
if (p.endsWith('remote-control.json')) {
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
}
|
|
return '';
|
|
}) as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
_resetForTesting();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// --- startRemoteControl ---
|
|
|
|
describe('startRemoteControl', () => {
|
|
it('spawns claude remote-control and returns the URL', async () => {
|
|
const proc = createMockProcess();
|
|
spawnMock.mockReturnValue(proc);
|
|
|
|
// Simulate URL appearing in stdout file on first poll
|
|
stdoutFileContent =
|
|
'Session URL: https://claude.ai/code?bridge=env_abc123\n';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
url: 'https://claude.ai/code?bridge=env_abc123',
|
|
});
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'claude',
|
|
['remote-control', '--name', 'NanoClaw Remote'],
|
|
expect.objectContaining({ cwd: '/project', detached: true }),
|
|
);
|
|
expect(proc.unref).toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses file descriptors for stdout/stderr (not pipes)', async () => {
|
|
const proc = createMockProcess();
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
const spawnCall = spawnMock.mock.calls[0];
|
|
const options = spawnCall[2];
|
|
// stdio[0] is 'pipe' so we can write 'y' to accept the prompt
|
|
expect(options.stdio[0]).toBe('pipe');
|
|
expect(typeof options.stdio[1]).toBe('number');
|
|
expect(typeof options.stdio[2]).toBe('number');
|
|
});
|
|
|
|
it('closes file descriptors in parent after spawn', async () => {
|
|
const proc = createMockProcess();
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
// Two openSync calls (stdout + stderr), two closeSync calls
|
|
expect(openSyncSpy).toHaveBeenCalledTimes(2);
|
|
expect(closeSyncSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('saves state to disk after capturing URL', async () => {
|
|
const proc = createMockProcess(99999);
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
|
STATE_FILE,
|
|
expect.stringContaining('"pid":99999'),
|
|
);
|
|
});
|
|
|
|
it('returns existing URL if session is already active', async () => {
|
|
const proc = createMockProcess();
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
// Second call should return existing URL without spawning
|
|
const result = await startRemoteControl('user2', 'tg:456', '/project');
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
url: 'https://claude.ai/code?bridge=env_existing',
|
|
});
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('starts new session if existing process is dead', async () => {
|
|
const proc1 = createMockProcess(11111);
|
|
const proc2 = createMockProcess(22222);
|
|
spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
|
|
|
|
// First start: process alive, URL found
|
|
const killSpy = vi
|
|
.spyOn(process, 'kill')
|
|
.mockImplementation((() => true) as any);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
// Old process (11111) is dead, new process (22222) is alive
|
|
killSpy.mockImplementation(((pid: number, sig: any) => {
|
|
if (pid === 11111 && (sig === 0 || sig === undefined)) {
|
|
throw new Error('ESRCH');
|
|
}
|
|
return true;
|
|
}) as any);
|
|
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
|
|
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
url: 'https://claude.ai/code?bridge=env_second',
|
|
});
|
|
expect(spawnMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns error if process exits before URL', async () => {
|
|
const proc = createMockProcess(33333);
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = '';
|
|
|
|
// Process is dead (poll will detect this)
|
|
vi.spyOn(process, 'kill').mockImplementation((() => {
|
|
throw new Error('ESRCH');
|
|
}) as any);
|
|
|
|
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Process exited before producing URL',
|
|
});
|
|
});
|
|
|
|
it('times out if URL never appears', async () => {
|
|
vi.useFakeTimers();
|
|
const proc = createMockProcess(44444);
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'no url here';
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
const promise = startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
// Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
|
|
for (let i = 0; i < 160; i++) {
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
}
|
|
|
|
const result = await promise;
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Timed out waiting for Remote Control URL',
|
|
});
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('returns error if spawn throws', async () => {
|
|
spawnMock.mockImplementation(() => {
|
|
throw new Error('ENOENT');
|
|
});
|
|
|
|
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Failed to start: ENOENT',
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- stopRemoteControl ---
|
|
|
|
describe('stopRemoteControl', () => {
|
|
it('kills the process and clears state', async () => {
|
|
const proc = createMockProcess(55555);
|
|
spawnMock.mockReturnValue(proc);
|
|
stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
|
|
const killSpy = vi
|
|
.spyOn(process, 'kill')
|
|
.mockImplementation((() => true) as any);
|
|
|
|
await startRemoteControl('user1', 'tg:123', '/project');
|
|
|
|
const result = stopRemoteControl();
|
|
expect(result).toEqual({ ok: true });
|
|
expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
|
|
expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
|
|
expect(getActiveSession()).toBeNull();
|
|
});
|
|
|
|
it('returns error when no session is active', () => {
|
|
const result = stopRemoteControl();
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'No active Remote Control session',
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- restoreRemoteControl ---
|
|
|
|
describe('restoreRemoteControl', () => {
|
|
it('restores session if state file exists and process is alive', () => {
|
|
const session = {
|
|
pid: 77777,
|
|
url: 'https://claude.ai/code?bridge=env_restored',
|
|
startedBy: 'user1',
|
|
startedInChat: 'tg:123',
|
|
startedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
return '';
|
|
}) as any);
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
restoreRemoteControl();
|
|
|
|
const active = getActiveSession();
|
|
expect(active).not.toBeNull();
|
|
expect(active!.pid).toBe(77777);
|
|
expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
|
|
});
|
|
|
|
it('clears state if process is dead', () => {
|
|
const session = {
|
|
pid: 88888,
|
|
url: 'https://claude.ai/code?bridge=env_dead',
|
|
startedBy: 'user1',
|
|
startedInChat: 'tg:123',
|
|
startedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
return '';
|
|
}) as any);
|
|
vi.spyOn(process, 'kill').mockImplementation((() => {
|
|
throw new Error('ESRCH');
|
|
}) as any);
|
|
|
|
restoreRemoteControl();
|
|
|
|
expect(getActiveSession()).toBeNull();
|
|
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing if no state file exists', () => {
|
|
// readFileSyncSpy default throws ENOENT for .json
|
|
restoreRemoteControl();
|
|
expect(getActiveSession()).toBeNull();
|
|
});
|
|
|
|
it('clears state on corrupted JSON', () => {
|
|
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
if (p.endsWith('remote-control.json')) return 'not json{{{';
|
|
return '';
|
|
}) as any);
|
|
|
|
restoreRemoteControl();
|
|
|
|
expect(getActiveSession()).toBeNull();
|
|
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
// ** This is the key integration test: restore → stop must work **
|
|
it('stopRemoteControl works after restoreRemoteControl', () => {
|
|
const session = {
|
|
pid: 77777,
|
|
url: 'https://claude.ai/code?bridge=env_restored',
|
|
startedBy: 'user1',
|
|
startedInChat: 'tg:123',
|
|
startedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
return '';
|
|
}) as any);
|
|
const killSpy = vi
|
|
.spyOn(process, 'kill')
|
|
.mockImplementation((() => true) as any);
|
|
|
|
restoreRemoteControl();
|
|
expect(getActiveSession()).not.toBeNull();
|
|
|
|
const result = stopRemoteControl();
|
|
expect(result).toEqual({ ok: true });
|
|
expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
|
|
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
expect(getActiveSession()).toBeNull();
|
|
});
|
|
|
|
it('startRemoteControl returns restored URL without spawning', () => {
|
|
const session = {
|
|
pid: 77777,
|
|
url: 'https://claude.ai/code?bridge=env_restored',
|
|
startedBy: 'user1',
|
|
startedInChat: 'tg:123',
|
|
startedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
return '';
|
|
}) as any);
|
|
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
|
|
restoreRemoteControl();
|
|
|
|
return startRemoteControl('user2', 'tg:456', '/project').then(
|
|
(result) => {
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
url: 'https://claude.ai/code?bridge=env_restored',
|
|
});
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|