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
24 changes: 24 additions & 0 deletions create-bestax/src/__tests__/project-creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,40 @@ const { ICON_LIBRARIES: _ICON_LIBRARIES, BULMA_FLAVORS: _BULMA_FLAVORS } =

// Mock console methods
const originalConsole = { ...console };
const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY'
);

function setStdinTTY(value: boolean | undefined): void {
Object.defineProperty(process.stdin, 'isTTY', {
value,
configurable: true,
writable: true,
});
}

function restoreStdinTTY(): void {
if (originalIsTTYDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
}

beforeEach(() => {
console.log = jest.fn();
console.error = jest.fn();
// ProjectCreator falls through to interactive prompts, which are guarded
// against non-TTY stdin (#192); jest's stdin is not a TTY, so simulate one.
setStdinTTY(true);
jest.clearAllMocks();
});

afterEach(() => {
console.log = originalConsole.log;
console.error = originalConsole.error;
restoreStdinTTY();
});

describe('ProjectCreator', () => {
Expand Down
62 changes: 62 additions & 0 deletions create-bestax/src/__tests__/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,81 @@ const {

// Mock console methods
const originalConsole = { ...console };
const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY'
);

function setStdinTTY(value: boolean | undefined): void {
Object.defineProperty(process.stdin, 'isTTY', {
value,
configurable: true,
writable: true,
});
}

function restoreStdinTTY(): void {
if (originalIsTTYDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
}

beforeEach(() => {
console.log = jest.fn();
console.error = jest.fn();
// Prompts are only reachable on an interactive terminal (#192); jest's
// stdin is not a TTY, so simulate one for the interactive tests.
setStdinTTY(true);
jest.clearAllMocks();
});

afterEach(() => {
console.log = originalConsole.log;
console.error = originalConsole.error;
restoreStdinTTY();
});

describe('prompts', () => {
describe('non-interactive guard (#192)', () => {
const promptFns: Array<[string, () => Promise<unknown>]> = [
['promptProjectName', () => promptProjectName()],
['promptOverwriteDirectory', () => promptOverwriteDirectory('project')],
['promptInstallSkills', () => promptInstallSkills()],
['promptTemplate', () => promptTemplate()],
['promptIconLibrary', () => promptIconLibrary()],
['promptBulmaFlavor', () => promptBulmaFlavor()],
];

it.each(promptFns)(
'%s exits with code 1 and guidance when stdin is not a TTY',
async (_name, fn) => {
setStdinTTY(undefined);
const originalExit = process.exit;
const exitMock = jest.fn((code?: number | string | null) => {
throw new Error(`process.exit(${code})`);
});
process.exit = exitMock as unknown as typeof process.exit;

try {
await expect(fn()).rejects.toThrow('process.exit(1)');
expect(exitMock).toHaveBeenCalledWith(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('No interactive terminal detected')
);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('-t vite-ts -b complete -i none -y')
);
// The underlying prompt must never start (it would hang forever).
expect(prompts).not.toHaveBeenCalled();
} finally {
process.exit = originalExit;
}
}
);
});

describe('promptProjectName', () => {
it('should prompt for project name', async () => {
(prompts as jest.MockedFunction<typeof prompts>).mockResolvedValue({
Expand Down
5 changes: 5 additions & 0 deletions create-bestax/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const MESSAGES = {
PROJECT_NAME_INVALID_CHARS:
'Project name can only contain letters, numbers, dots, dashes and underscores',
OPERATION_CANCELLED: '✖ Operation cancelled',
NO_TTY:
'No interactive terminal detected — cannot prompt for input.\n' +
'Re-run non-interactively with a project name and flags, e.g.:\n' +
' npm create bestax@latest my-app -- -t vite-ts -b complete -i none -y\n' +
'Run with --help to see all options.',
DIRECTORY_NOT_EMPTY: (dir: string) =>
`Directory ${chalk.yellow(dir)} is not empty. Remove existing files and continue?`,
EMPTYING_DIRECTORY: (dir: string) => `\n Emptying ${dir}...`,
Expand Down
19 changes: 19 additions & 0 deletions create-bestax/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ import {
BULMA_FLAVORS,
} from './constants.js';
import { validateProjectName } from './validators.js';
import { displayError } from './display.js';

/**
* Guard against hanging forever in non-interactive environments (CI, agents,
* piped stdin): every interactive question must fail fast with actionable
* guidance when stdin is not a TTY (#192).
*/
function ensureInteractive(): void {
if (!process.stdin.isTTY) {
displayError(MESSAGES.NO_TTY);
process.exit(1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export async function promptProjectName(): Promise<string | null> {
ensureInteractive();
const response = await prompts({
type: 'text',
name: 'projectName',
Expand All @@ -24,6 +38,7 @@ export async function promptProjectName(): Promise<string | null> {
export async function promptOverwriteDirectory(
targetDir: string
): Promise<boolean> {
ensureInteractive();
const response = await prompts({
type: 'confirm',
name: 'overwrite',
Expand All @@ -35,6 +50,7 @@ export async function promptOverwriteDirectory(
}

export async function promptInstallSkills(): Promise<boolean> {
ensureInteractive();
const response = await prompts({
type: 'confirm',
name: 'skills',
Expand All @@ -46,6 +62,7 @@ export async function promptInstallSkills(): Promise<boolean> {
}

export async function promptTemplate(): Promise<string | null> {
ensureInteractive();
const response = await prompts({
type: 'select',
name: 'template',
Expand All @@ -60,6 +77,7 @@ export async function promptTemplate(): Promise<string | null> {
}

export async function promptIconLibrary(): Promise<string | null> {
ensureInteractive();
const response = await prompts({
type: 'select',
name: 'iconLibrary',
Expand All @@ -75,6 +93,7 @@ export async function promptIconLibrary(): Promise<string | null> {
}

export async function promptBulmaFlavor(): Promise<string | null> {
ensureInteractive();
const response = await prompts({
type: 'select',
name: 'bulmaFlavor',
Expand Down
Loading