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
102 changes: 102 additions & 0 deletions packages/inquirerer-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# @inquirerer/test

Testing utilities for [inquirerer](https://www.npmjs.com/package/inquirerer)-based CLI applications.

## Installation

```bash
npm install --save-dev @inquirerer/test
```

## Usage

### Basic Test Setup

```typescript
import { createTestEnvironment, KEY_SEQUENCES } from '@inquirerer/test';
import { Inquirerer } from 'inquirerer';

describe('my CLI', () => {
let env;

beforeEach(() => {
env = createTestEnvironment();
});

it('should handle user input', async () => {
// Queue up user inputs
env.sendKey(KEY_SEQUENCES.ENTER);

const prompter = new Inquirerer(env.options);
const result = await prompter.prompt({}, [
{ name: 'confirm', type: 'confirm', message: 'Continue?' }
]);

expect(result.confirm).toBe(true);
});
});
```

### Key Sequences

The package exports common key sequences for simulating user input:

```typescript
import { KEY_SEQUENCES } from '@inquirerer/test';

KEY_SEQUENCES.ENTER // Enter/Return key
KEY_SEQUENCES.UP_ARROW // Up arrow
KEY_SEQUENCES.DOWN_ARROW // Down arrow
KEY_SEQUENCES.SPACE // Space bar
KEY_SEQUENCES.TAB // Tab key
KEY_SEQUENCES.ESCAPE // Escape key
KEY_SEQUENCES.BACKSPACE // Backspace
KEY_SEQUENCES.CTRL_C // Ctrl+C (interrupt)
KEY_SEQUENCES.CTRL_D // Ctrl+D (EOF)
```

### Snapshot Utilities

Normalize package.json files for stable snapshots:

```typescript
import { normalizePackageJsonForSnapshot } from '@inquirerer/test';

const pkgJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
const normalized = normalizePackageJsonForSnapshot(pkgJson, {
preserveVersionsFor: ['my-important-package']
});

expect(normalized).toMatchSnapshot();
```

Normalize paths and dates for cross-platform snapshots:

```typescript
import { normalizePathsForSnapshot, normalizeDatesForSnapshot } from '@inquirerer/test';

const output = normalizePathsForSnapshot(rawOutput);
const stableOutput = normalizeDatesForSnapshot(output);

expect(stableOutput).toMatchSnapshot();
```

### TestEnvironment API

The `createTestEnvironment()` function returns a `TestEnvironment` object with:

| Property | Type | Description |
|----------|------|-------------|
| `options` | `Partial<CLIOptions>` | CLI options configured with mock streams |
| `mockInput` | `Readable` | Mock stdin stream |
| `mockOutput` | `Writable` | Mock stdout stream |
| `writeResults` | `string[]` | Captured output lines (ANSI stripped) |
| `enqueueInputResponse` | `(input) => void` | Queue an input response |
| `sendKey` | `(key) => void` | Send a key sequence immediately |
| `sendLine` | `(text) => void` | Send text input (for readline) |
| `getOutput` | `() => string` | Get all captured output |
| `clearOutput` | `() => void` | Clear captured output |

## License

MIT
15 changes: 15 additions & 0 deletions packages/inquirerer-test/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: false,
},
],
},
};
56 changes: 56 additions & 0 deletions packages/inquirerer-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@inquirerer/test",
"version": "1.0.0",
"author": "Constructive <developers@constructive.io>",
"description": "Testing utilities for inquirerer-based CLI applications",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/dev-utils",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/dev-utils"
},
"bugs": {
"url": "https://github.com/constructive-io/dev-utils/issues"
},
"scripts": {
"copy": "makage assets",
"clean": "makage clean",
"prepublishOnly": "npm run build",
"build": "makage build",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"dependencies": {
"clean-ansi": "workspace:*",
"inquirerer": "workspace:*"
},
"peerDependencies": {
"jest": ">=29.0.0"
},
"peerDependenciesMeta": {
"jest": {
"optional": true
}
},
"devDependencies": {
"@types/jest": "^30.0.0",
"jest": "^30.0.0",
"makage": "0.1.8"
},
"keywords": [
"cli",
"testing",
"inquirerer",
"test-utils",
"mock",
"stdin",
"stdout"
]
}
210 changes: 210 additions & 0 deletions packages/inquirerer-test/src/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { CLIOptions } from 'inquirerer';
import readline from 'readline';
import { Readable, Transform, Writable } from 'stream';
import { cleanAnsi } from 'clean-ansi';
import { humanizeKeySequences } from './keys';

/**
* Represents a queued input response for the test harness.
*/
export interface InputResponse {
/** Type of input: 'key' for keypress, 'read' for readline input */
type: 'key' | 'read';
/** The value to send (key sequence or text input) */
value: string;
}

interface MockReadline {
question: (questionText: string, cb: (input: string) => void) => void;
close: () => void;
}

/**
* The test environment provides mock streams and utilities for testing CLI applications.
*/
export interface TestEnvironment {
/** CLI options configured with mock streams */
options: Partial<CLIOptions>;
/** Mock input stream (stdin) */
mockInput: Readable;
/** Mock output stream (stdout) */
mockOutput: Writable;
/** Captured output lines (ANSI stripped, key sequences humanized) */
writeResults: string[];
/** Captured transform stream results */
transformResults: string[];
/** Queue an input response to be sent to the CLI */
enqueueInputResponse: (input: InputResponse) => void;
/** Send a key sequence immediately */
sendKey: (key: string) => void;
/** Send text input followed by Enter */
sendLine: (text: string) => void;
/** Get all captured output as a single string */
getOutput: () => string;
/** Clear captured output */
clearOutput: () => void;
}

function setupReadlineMock(
inputQueue: InputResponse[],
getCurrentIndex: () => number,
incrementIndex: () => void
): void {
const originalCreateInterface = readline.createInterface;

readline.createInterface = jest.fn().mockReturnValue({
question: (questionText: string, cb: (input: string) => void) => {
const currentIndex = getCurrentIndex();
const nextInput = inputQueue[currentIndex];
if (nextInput && nextInput.type === 'read') {
incrementIndex();
setTimeout(() => cb(nextInput.value), 1);
}
},
close: jest.fn(),
} as MockReadline);
}

/**
* Creates a test environment for testing inquirerer-based CLI applications.
* Call this in your test's beforeEach to get a fresh environment for each test.
*
* @example
* ```typescript
* import { createTestEnvironment, KEY_SEQUENCES } from '@inquirerer/test';
*
* describe('my CLI', () => {
* let env: TestEnvironment;
*
* beforeEach(() => {
* env = createTestEnvironment();
* });
*
* it('should handle user input', async () => {
* env.enqueueInputResponse({ type: 'key', value: KEY_SEQUENCES.ENTER });
*
* const prompter = new Inquirerer(env.options);
* const result = await prompter.prompt({}, questions);
*
* expect(env.getOutput()).toContain('expected output');
* });
* });
* ```
*/
export function createTestEnvironment(): TestEnvironment {
const writeResults: string[] = [];
const transformResults: string[] = [];
const inputQueue: InputResponse[] = [];
let currentInputIndex = 0;
let lastScheduledTime = 0;

// Clear any previous mocks
if (typeof jest !== 'undefined') {
jest.clearAllMocks();
}

const mockInput = new Readable({ read() {} });
(mockInput as any).setRawMode = jest.fn();

const mockOutput = new Writable({
write: (chunk, encoding, callback) => {
const str = chunk.toString();
const humanizedStr = humanizeKeySequences(str);
const cleanStr = cleanAnsi(humanizedStr);
writeResults.push(cleanStr);
callback();
}
});

const options: Partial<CLIOptions> = {
noTty: false,
input: mockInput,
output: mockOutput,
minimistOpts: {
alias: {
v: 'version'
}
}
};

const transformStream = new Transform({
transform(chunk, encoding, callback) {
const data = chunk.toString();
const humanizedData = humanizeKeySequences(data);
const cleanData = cleanAnsi(humanizedData);
transformResults.push(cleanData);
this.push(chunk);
callback();
}
});

setupReadlineMock(
inputQueue,
() => currentInputIndex,
() => { currentInputIndex++; }
);
mockInput.pipe(transformStream);

const enqueueInputResponse = (input: InputResponse) => {
lastScheduledTime += 1;

if (input.type === 'key') {
setTimeout(() => mockInput.push(input.value), lastScheduledTime);
} else {
inputQueue.push(input);
}
};

const sendKey = (key: string) => {
enqueueInputResponse({ type: 'key', value: key });
};

const sendLine = (text: string) => {
enqueueInputResponse({ type: 'read', value: text });
};

const getOutput = () => writeResults.join('');

const clearOutput = () => {
writeResults.length = 0;
transformResults.length = 0;
};

return {
options,
mockInput,
mockOutput,
writeResults,
transformResults,
enqueueInputResponse,
sendKey,
sendLine,
getOutput,
clearOutput
};
}

/**
* Sets up test utilities and returns a function that creates a fresh TestEnvironment.
* This is useful for Jest's beforeEach pattern.
*
* @example
* ```typescript
* import { setupTests } from '@inquirerer/test';
*
* const getEnv = setupTests();
*
* describe('my CLI', () => {
* let env: TestEnvironment;
*
* beforeEach(() => {
* env = getEnv();
* });
*
* // ... tests
* });
* ```
*/
export function setupTests(): () => TestEnvironment {
return createTestEnvironment;
}
Loading