diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index 94fb563..9ef5942 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -12,8 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, spyOn } from 'bun:test'; import { readInput, FileReadError, formatOutput } from './utils.js'; +import type { StdinStream } from './utils.js'; + +function makeStdin(content: string, isTTY: boolean): StdinStream { + async function* gen() { yield Buffer.from(content); } + return Object.assign(gen(), { isTTY }); +} describe('readInput', () => { it('throws FileReadError when file does not exist', async () => { @@ -50,6 +56,41 @@ describe('readInput', () => { }); }); +describe('readInput stdin ("-")', () => { + it('reads content from a piped stream', async () => { + const result = await readInput('-', makeStdin('hello world', false)); + expect(result).toBe('hello world'); + }); + + it('returns empty string for an empty piped stream', async () => { + async function* empty() {} + const result = await readInput('-', Object.assign(empty(), { isTTY: false })); + expect(result).toBe(''); + }); + + it('writes a hint to stderr when stdin is a TTY', async () => { + const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await readInput('-', makeStdin('some content', true)); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Press Ctrl+D') + ); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('does not write a hint when stdin is not a TTY', async () => { + const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await readInput('-', makeStdin('piped content', false)); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stderrSpy.mockRestore(); + } + }); +}); + describe('formatOutput', () => { describe('--format markdown', () => { it('renders a lint report instead of [object Object]', () => { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index fac6e64..a6a15b0 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -14,6 +14,8 @@ import { readFileSync } from 'node:fs'; +export type StdinStream = AsyncIterable & { isTTY?: boolean }; + export class FileReadError extends Error { readonly code = 'FILE_READ_ERROR' as const; constructor(public readonly filePath: string, cause: unknown) { @@ -37,11 +39,14 @@ export class FileReadError extends Error { * Read input from a file path or stdin ("-"). * Throws FileReadError if the file cannot be read. */ -export async function readInput(filePath: string): Promise { +export async function readInput(filePath: string, stdin: StdinStream = process.stdin): Promise { if (filePath === '-') { + if (stdin.isTTY) { + process.stderr.write('Reading from stdin… Press Ctrl+D when done.\n'); + } const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); + for await (const chunk of stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks).toString('utf-8'); }