Skip to content
Draft
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
84 changes: 84 additions & 0 deletions src/cli/commands/dev/__tests__/tty-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { launchTuiDevScreenWithPicker } from '../browser-mode';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Drive the REAL requireTTY by toggling process.stdin.isTTY / process.stdout.isTTY
// and spying on process.exit / console.error. Only the I/O boundaries are mocked:
// the Ink renderer and the DevScreen the picker renders. This pins the guard that
// PR #1640 centralized — the browser-mode harness picker must refuse to render in a
// non-TTY context instead of throwing Ink's "Raw mode is not supported" stack trace.
const mockRender = vi.fn((..._args: unknown[]) => ({
unmount: vi.fn(),
waitUntilExit: () => Promise.resolve(),
}));

vi.mock('ink', () => ({
render: (...args: unknown[]) => mockRender(...args),
}));

vi.mock('../../../tui/screens/dev/DevScreen', () => ({ DevScreen: () => null }));

describe('dev browser-mode picker TTY guard', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
const origStdinIsTTY = process.stdin.isTTY;
const origStdoutIsTTY = process.stdout.isTTY;

beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${code})`);
});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
});

afterEach(() => {
process.stdin.isTTY = origStdinIsTTY;
process.stdout.isTTY = origStdoutIsTTY;
exitSpy.mockRestore();
errorSpy.mockRestore();
stdoutWriteSpy.mockRestore();
vi.clearAllMocks();
});

it('exits with code 1 and never renders in a non-TTY context', async () => {
process.stdin.isTTY = false;
process.stdout.isTTY = false;

await expect(launchTuiDevScreenWithPicker('/tmp/project')).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('requires an interactive terminal'));
expect(mockRender).not.toHaveBeenCalled();
});

it('exits when only stdin is not a TTY', async () => {
process.stdin.isTTY = false;
process.stdout.isTTY = true;

await expect(launchTuiDevScreenWithPicker('/tmp/project')).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockRender).not.toHaveBeenCalled();
});

it('exits when only stdout is not a TTY', async () => {
process.stdin.isTTY = true;
process.stdout.isTTY = false;

await expect(launchTuiDevScreenWithPicker('/tmp/project')).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockRender).not.toHaveBeenCalled();
});

it('renders the picker when both stdin and stdout are TTYs', async () => {
process.stdin.isTTY = true;
process.stdout.isTTY = true;

await launchTuiDevScreenWithPicker('/tmp/project');

expect(exitSpy).not.toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions src/cli/commands/dev/browser-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memor
import { loadDeployedProjectConfig, resolveAgentOrHarness } from '../../operations/resolve-agent';
import { fetchTraceRecords, listTraces } from '../../operations/traces';
import { LayoutProvider } from '../../tui/context';
import { requireTTY } from '../../tui/guards';
import { runCliDeploy } from '../deploy/progress';
import { render } from 'ink';
import path from 'node:path';
Expand Down Expand Up @@ -328,6 +329,7 @@ export async function launchTuiDevScreenWithPicker(
workingDir: string,
options?: { skipDeploy?: boolean }
): Promise<TuiPickerResult | undefined> {
requireTTY();
process.stdout.write(ENTER_ALT_SCREEN);

const exitAltScreen = () => {
Expand Down
87 changes: 87 additions & 0 deletions src/cli/commands/export/__tests__/tty-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { registerExport } from '../index';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Drive the REAL requireTTY (centralized inside renderTUI) by toggling
// process.stdin.isTTY / process.stdout.isTTY and spying on process.exit /
// console.error. Only the I/O boundaries renderTUI touches are mocked: the
// Ink renderer, telemetry, and post-command notices. The guard runs first in
// renderTUI, so a non-TTY context exits before any of these are reached.
const mockRender = vi.fn((..._args: unknown[]) => ({ waitUntilExit: () => Promise.resolve() }));

vi.mock('ink', () => ({
render: (...args: unknown[]) => mockRender(...args),
}));

vi.mock('../../../telemetry', () => ({
TelemetryClientAccessor: {
init: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue(null),
},
}));

vi.mock('../../../notices', () => ({
printPostCommandNotices: vi.fn().mockResolvedValue(undefined),
}));

describe('export harness TTY guard', () => {
let program: Command;
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let writeSpy: ReturnType<typeof vi.spyOn>;
const origStdinIsTTY = process.stdin.isTTY;
const origStdoutIsTTY = process.stdout.isTTY;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerExport(program);

// Swallow the alt-screen escape sequences renderTUI writes on the TTY path.
writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${code})`);
});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
process.stdin.isTTY = origStdinIsTTY;
process.stdout.isTTY = origStdoutIsTTY;
writeSpy.mockRestore();
exitSpy.mockRestore();
errorSpy.mockRestore();
vi.clearAllMocks();
});

it('exits with code 1 and never renders the TUI in a non-TTY context', async () => {
process.stdin.isTTY = false;
process.stdout.isTTY = false;

await expect(program.parseAsync(['export', 'harness'], { from: 'user' })).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('requires an interactive terminal'));
expect(mockRender).not.toHaveBeenCalled();
});

it('exits when only stdout is not a TTY', async () => {
process.stdin.isTTY = true;
process.stdout.isTTY = false;

await expect(program.parseAsync(['export', 'harness'], { from: 'user' })).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockRender).not.toHaveBeenCalled();
});

it('renders the TUI when both stdin and stdout are TTYs', async () => {
process.stdin.isTTY = true;
process.stdout.isTTY = true;

await program.parseAsync(['export', 'harness'], { from: 'user' });

expect(exitSpy).not.toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions src/cli/commands/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function registerExport(program: Command): void {
console.log(JSON.stringify({ success: false, error: '--name is required in non-interactive mode' }));
process.exit(1);
}
// renderTUI() guards for an interactive terminal before rendering.
await renderTUI({ initialRoute: { name: 'export-harness' }, actionOnBack: 'exit' });
return;
}
Expand Down
81 changes: 81 additions & 0 deletions src/cli/commands/import/__tests__/tty-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { registerImport } from '../command';
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Drive the REAL requireTTY by toggling process.stdin.isTTY / process.stdout.isTTY
// and spying on process.exit / console.error. Only the I/O boundaries are mocked:
// the Ink renderer and the ImportFlow screen. requireProject is stubbed to a no-op
// so the TTY guard (which runs after it) is what we exercise here.
const mockRender = vi.fn((..._args: unknown[]) => ({ clear: vi.fn(), unmount: vi.fn() }));

vi.mock('../../../tui/guards', async importOriginal => {
const actual = await importOriginal<typeof import('../../../tui/guards')>();
return {
...actual,
requireProject: vi.fn(),
};
});

vi.mock('ink', () => ({
render: (...args: unknown[]) => mockRender(...args),
}));

vi.mock('../../../tui/screens/import', () => ({ ImportFlow: () => null }));

describe('import non-source TTY guard', () => {
let program: Command;
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
const origStdinIsTTY = process.stdin.isTTY;
const origStdoutIsTTY = process.stdout.isTTY;

beforeEach(() => {
program = new Command();
program.exitOverride();
registerImport(program);

exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${code})`);
});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
process.stdin.isTTY = origStdinIsTTY;
process.stdout.isTTY = origStdoutIsTTY;
exitSpy.mockRestore();
errorSpy.mockRestore();
vi.clearAllMocks();
});

it('exits with code 1 and does not render in a non-TTY context', async () => {
process.stdin.isTTY = false;
process.stdout.isTTY = false;

await expect(program.parseAsync(['import'], { from: 'user' })).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('requires an interactive terminal'));
expect(mockRender).not.toHaveBeenCalled();
});

it('exits when only stdin is not a TTY', async () => {
process.stdin.isTTY = false;
process.stdout.isTTY = true;

await expect(program.parseAsync(['import'], { from: 'user' })).rejects.toThrow('process.exit(1)');

expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockRender).not.toHaveBeenCalled();
});

it('renders the interactive flow when both stdin and stdout are TTYs', async () => {
process.stdin.isTTY = true;
process.stdout.isTTY = true;

await program.parseAsync(['import'], { from: 'user' });

expect(exitSpy).not.toHaveBeenCalled();
expect(mockRender).toHaveBeenCalled();
});
});
7 changes: 5 additions & 2 deletions src/cli/commands/import/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ export const registerImport = (program: Command) => {
.option('-y, --yes', 'Auto-confirm prompts')
.action(async (cliOptions: { source?: string; target?: string; yes?: boolean }) => {
if (!cliOptions.source) {
// No --source and no subcommand — launch interactive TUI
const { requireProject } = await import('../../tui/guards/project');
// No --source and no subcommand — launch interactive TUI.
// requireProject() first so users who haven't cd'd to a project get the
// more actionable error before the TTY check (consistent with `view`).
const { requireProject, requireTTY } = await import('../../tui/guards');
requireProject();
requireTTY();
const { render } = await import('ink');
const React = await import('react');
const { ImportFlow } = await import('../../tui/screens/import');
Expand Down
Loading
Loading