Skip to content

Commit 105af30

Browse files
committed
feat: add @inquirerer/test package and skip logic for checkForUpdates
@inquirerer/utils: - Add shouldSkipUpdateCheck() helper that checks CI env and skip env vars - Update checkForUpdates to auto-skip in CI or when INQUIRERER_SKIP_UPDATE_CHECK is set - Support tool-specific skip via {TOOLNAME}_SKIP_UPDATE_CHECK (e.g., PGPM_SKIP_UPDATE_CHECK) - Add 'force' option to bypass skip logic - Return 'skipped' and 'skipReason' fields in result @inquirerer/test (new package): - CliTestHarness with mock stdin/stdout/TTY for testing CLI apps - KEY_SEQUENCES constants (ENTER, UP_ARROW, DOWN_ARROW, SPACE, etc.) - setupTests() / createTestEnvironment() for Jest integration - normalizePackageJsonForSnapshot() for stable dependency snapshots - normalizePathsForSnapshot() and normalizeDatesForSnapshot() utilities - humanizeKeySequences() for readable test output
1 parent ab5945f commit 105af30

File tree

12 files changed

+3212
-5114
lines changed

12 files changed

+3212
-5114
lines changed

packages/inquirerer-test/README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# @inquirerer/test
2+
3+
Testing utilities for [inquirerer](https://www.npmjs.com/package/inquirerer)-based CLI applications.
4+
5+
## Installation
6+
7+
```bash
8+
npm install --save-dev @inquirerer/test
9+
```
10+
11+
## Usage
12+
13+
### Basic Test Setup
14+
15+
```typescript
16+
import { createTestEnvironment, KEY_SEQUENCES } from '@inquirerer/test';
17+
import { Inquirerer } from 'inquirerer';
18+
19+
describe('my CLI', () => {
20+
let env;
21+
22+
beforeEach(() => {
23+
env = createTestEnvironment();
24+
});
25+
26+
it('should handle user input', async () => {
27+
// Queue up user inputs
28+
env.sendKey(KEY_SEQUENCES.ENTER);
29+
30+
const prompter = new Inquirerer(env.options);
31+
const result = await prompter.prompt({}, [
32+
{ name: 'confirm', type: 'confirm', message: 'Continue?' }
33+
]);
34+
35+
expect(result.confirm).toBe(true);
36+
});
37+
});
38+
```
39+
40+
### Key Sequences
41+
42+
The package exports common key sequences for simulating user input:
43+
44+
```typescript
45+
import { KEY_SEQUENCES } from '@inquirerer/test';
46+
47+
KEY_SEQUENCES.ENTER // Enter/Return key
48+
KEY_SEQUENCES.UP_ARROW // Up arrow
49+
KEY_SEQUENCES.DOWN_ARROW // Down arrow
50+
KEY_SEQUENCES.SPACE // Space bar
51+
KEY_SEQUENCES.TAB // Tab key
52+
KEY_SEQUENCES.ESCAPE // Escape key
53+
KEY_SEQUENCES.BACKSPACE // Backspace
54+
KEY_SEQUENCES.CTRL_C // Ctrl+C (interrupt)
55+
KEY_SEQUENCES.CTRL_D // Ctrl+D (EOF)
56+
```
57+
58+
### Snapshot Utilities
59+
60+
Normalize package.json files for stable snapshots:
61+
62+
```typescript
63+
import { normalizePackageJsonForSnapshot } from '@inquirerer/test';
64+
65+
const pkgJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
66+
const normalized = normalizePackageJsonForSnapshot(pkgJson, {
67+
preserveVersionsFor: ['my-important-package']
68+
});
69+
70+
expect(normalized).toMatchSnapshot();
71+
```
72+
73+
Normalize paths and dates for cross-platform snapshots:
74+
75+
```typescript
76+
import { normalizePathsForSnapshot, normalizeDatesForSnapshot } from '@inquirerer/test';
77+
78+
const output = normalizePathsForSnapshot(rawOutput);
79+
const stableOutput = normalizeDatesForSnapshot(output);
80+
81+
expect(stableOutput).toMatchSnapshot();
82+
```
83+
84+
### TestEnvironment API
85+
86+
The `createTestEnvironment()` function returns a `TestEnvironment` object with:
87+
88+
| Property | Type | Description |
89+
|----------|------|-------------|
90+
| `options` | `Partial<CLIOptions>` | CLI options configured with mock streams |
91+
| `mockInput` | `Readable` | Mock stdin stream |
92+
| `mockOutput` | `Writable` | Mock stdout stream |
93+
| `writeResults` | `string[]` | Captured output lines (ANSI stripped) |
94+
| `enqueueInputResponse` | `(input) => void` | Queue an input response |
95+
| `sendKey` | `(key) => void` | Send a key sequence immediately |
96+
| `sendLine` | `(text) => void` | Send text input (for readline) |
97+
| `getOutput` | `() => string` | Get all captured output |
98+
| `clearOutput` | `() => void` | Clear captured output |
99+
100+
## License
101+
102+
MIT
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
testMatch: ['**/__tests__/**/*.test.ts'],
6+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7+
transform: {
8+
'^.+\\.tsx?$': [
9+
'ts-jest',
10+
{
11+
useESM: false,
12+
},
13+
],
14+
},
15+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@inquirerer/test",
3+
"version": "1.0.0",
4+
"author": "Constructive <developers@constructive.io>",
5+
"description": "Testing utilities for inquirerer-based CLI applications",
6+
"main": "index.js",
7+
"module": "esm/index.js",
8+
"types": "index.d.ts",
9+
"homepage": "https://github.com/constructive-io/dev-utils",
10+
"license": "MIT",
11+
"publishConfig": {
12+
"access": "public",
13+
"directory": "dist"
14+
},
15+
"repository": {
16+
"type": "git",
17+
"url": "https://github.com/constructive-io/dev-utils"
18+
},
19+
"bugs": {
20+
"url": "https://github.com/constructive-io/dev-utils/issues"
21+
},
22+
"scripts": {
23+
"copy": "makage assets",
24+
"clean": "makage clean",
25+
"prepublishOnly": "npm run build",
26+
"build": "makage build",
27+
"test": "jest --passWithNoTests",
28+
"test:watch": "jest --watch"
29+
},
30+
"dependencies": {
31+
"clean-ansi": "workspace:*",
32+
"inquirerer": "workspace:*"
33+
},
34+
"peerDependencies": {
35+
"jest": ">=29.0.0"
36+
},
37+
"peerDependenciesMeta": {
38+
"jest": {
39+
"optional": true
40+
}
41+
},
42+
"devDependencies": {
43+
"@types/jest": "^30.0.0",
44+
"jest": "^30.0.0",
45+
"makage": "0.1.8"
46+
},
47+
"keywords": [
48+
"cli",
49+
"testing",
50+
"inquirerer",
51+
"test-utils",
52+
"mock",
53+
"stdin",
54+
"stdout"
55+
]
56+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { CLIOptions } from 'inquirerer';
2+
import readline from 'readline';
3+
import { Readable, Transform, Writable } from 'stream';
4+
import { cleanAnsi } from 'clean-ansi';
5+
import { humanizeKeySequences } from './keys';
6+
7+
/**
8+
* Represents a queued input response for the test harness.
9+
*/
10+
export interface InputResponse {
11+
/** Type of input: 'key' for keypress, 'read' for readline input */
12+
type: 'key' | 'read';
13+
/** The value to send (key sequence or text input) */
14+
value: string;
15+
}
16+
17+
interface MockReadline {
18+
question: (questionText: string, cb: (input: string) => void) => void;
19+
close: () => void;
20+
}
21+
22+
/**
23+
* The test environment provides mock streams and utilities for testing CLI applications.
24+
*/
25+
export interface TestEnvironment {
26+
/** CLI options configured with mock streams */
27+
options: Partial<CLIOptions>;
28+
/** Mock input stream (stdin) */
29+
mockInput: Readable;
30+
/** Mock output stream (stdout) */
31+
mockOutput: Writable;
32+
/** Captured output lines (ANSI stripped, key sequences humanized) */
33+
writeResults: string[];
34+
/** Captured transform stream results */
35+
transformResults: string[];
36+
/** Queue an input response to be sent to the CLI */
37+
enqueueInputResponse: (input: InputResponse) => void;
38+
/** Send a key sequence immediately */
39+
sendKey: (key: string) => void;
40+
/** Send text input followed by Enter */
41+
sendLine: (text: string) => void;
42+
/** Get all captured output as a single string */
43+
getOutput: () => string;
44+
/** Clear captured output */
45+
clearOutput: () => void;
46+
}
47+
48+
function setupReadlineMock(
49+
inputQueue: InputResponse[],
50+
getCurrentIndex: () => number,
51+
incrementIndex: () => void
52+
): void {
53+
const originalCreateInterface = readline.createInterface;
54+
55+
readline.createInterface = jest.fn().mockReturnValue({
56+
question: (questionText: string, cb: (input: string) => void) => {
57+
const currentIndex = getCurrentIndex();
58+
const nextInput = inputQueue[currentIndex];
59+
if (nextInput && nextInput.type === 'read') {
60+
incrementIndex();
61+
setTimeout(() => cb(nextInput.value), 1);
62+
}
63+
},
64+
close: jest.fn(),
65+
} as MockReadline);
66+
}
67+
68+
/**
69+
* Creates a test environment for testing inquirerer-based CLI applications.
70+
* Call this in your test's beforeEach to get a fresh environment for each test.
71+
*
72+
* @example
73+
* ```typescript
74+
* import { createTestEnvironment, KEY_SEQUENCES } from '@inquirerer/test';
75+
*
76+
* describe('my CLI', () => {
77+
* let env: TestEnvironment;
78+
*
79+
* beforeEach(() => {
80+
* env = createTestEnvironment();
81+
* });
82+
*
83+
* it('should handle user input', async () => {
84+
* env.enqueueInputResponse({ type: 'key', value: KEY_SEQUENCES.ENTER });
85+
*
86+
* const prompter = new Inquirerer(env.options);
87+
* const result = await prompter.prompt({}, questions);
88+
*
89+
* expect(env.getOutput()).toContain('expected output');
90+
* });
91+
* });
92+
* ```
93+
*/
94+
export function createTestEnvironment(): TestEnvironment {
95+
const writeResults: string[] = [];
96+
const transformResults: string[] = [];
97+
const inputQueue: InputResponse[] = [];
98+
let currentInputIndex = 0;
99+
let lastScheduledTime = 0;
100+
101+
// Clear any previous mocks
102+
if (typeof jest !== 'undefined') {
103+
jest.clearAllMocks();
104+
}
105+
106+
const mockInput = new Readable({ read() {} });
107+
(mockInput as any).setRawMode = jest.fn();
108+
109+
const mockOutput = new Writable({
110+
write: (chunk, encoding, callback) => {
111+
const str = chunk.toString();
112+
const humanizedStr = humanizeKeySequences(str);
113+
const cleanStr = cleanAnsi(humanizedStr);
114+
writeResults.push(cleanStr);
115+
callback();
116+
}
117+
});
118+
119+
const options: Partial<CLIOptions> = {
120+
noTty: false,
121+
input: mockInput,
122+
output: mockOutput,
123+
minimistOpts: {
124+
alias: {
125+
v: 'version'
126+
}
127+
}
128+
};
129+
130+
const transformStream = new Transform({
131+
transform(chunk, encoding, callback) {
132+
const data = chunk.toString();
133+
const humanizedData = humanizeKeySequences(data);
134+
const cleanData = cleanAnsi(humanizedData);
135+
transformResults.push(cleanData);
136+
this.push(chunk);
137+
callback();
138+
}
139+
});
140+
141+
setupReadlineMock(
142+
inputQueue,
143+
() => currentInputIndex,
144+
() => { currentInputIndex++; }
145+
);
146+
mockInput.pipe(transformStream);
147+
148+
const enqueueInputResponse = (input: InputResponse) => {
149+
lastScheduledTime += 1;
150+
151+
if (input.type === 'key') {
152+
setTimeout(() => mockInput.push(input.value), lastScheduledTime);
153+
} else {
154+
inputQueue.push(input);
155+
}
156+
};
157+
158+
const sendKey = (key: string) => {
159+
enqueueInputResponse({ type: 'key', value: key });
160+
};
161+
162+
const sendLine = (text: string) => {
163+
enqueueInputResponse({ type: 'read', value: text });
164+
};
165+
166+
const getOutput = () => writeResults.join('');
167+
168+
const clearOutput = () => {
169+
writeResults.length = 0;
170+
transformResults.length = 0;
171+
};
172+
173+
return {
174+
options,
175+
mockInput,
176+
mockOutput,
177+
writeResults,
178+
transformResults,
179+
enqueueInputResponse,
180+
sendKey,
181+
sendLine,
182+
getOutput,
183+
clearOutput
184+
};
185+
}
186+
187+
/**
188+
* Sets up test utilities and returns a function that creates a fresh TestEnvironment.
189+
* This is useful for Jest's beforeEach pattern.
190+
*
191+
* @example
192+
* ```typescript
193+
* import { setupTests } from '@inquirerer/test';
194+
*
195+
* const getEnv = setupTests();
196+
*
197+
* describe('my CLI', () => {
198+
* let env: TestEnvironment;
199+
*
200+
* beforeEach(() => {
201+
* env = getEnv();
202+
* });
203+
*
204+
* // ... tests
205+
* });
206+
* ```
207+
*/
208+
export function setupTests(): () => TestEnvironment {
209+
return createTestEnvironment;
210+
}

0 commit comments

Comments
 (0)