From f798bba09521dabaa9933cdfa7197cfb77eeb0c8 Mon Sep 17 00:00:00 2001 From: cy <113548315+red40maxxer@users.noreply.github.com> Date: Tue, 11 Nov 2025 11:35:52 -0500 Subject: [PATCH] test(cli): add tests for stdin input (#107) * test(cli): add tests for stdin input * test(cli): extract mock stdin to helper function * test(cli): add comprehensive tests for stdin edge cases and output file handling * refactor(test): streamline mockStdin function and relocate to utils * test(cli): remove redundant test for JSON encoding from stdin * test(cli): restore mocks consistently * test(cli): restructured output file tests and modified some assertions * chore: fix linting issues & remove redundant cleanups --------- Co-authored-by: mad-cat-lon <113548315+mad-cat-lon@users.noreply.github.com> Co-authored-by: Johann Schopplich --- packages/cli/test/index.test.ts | 195 +++++++++++++++++++++++++++++++- packages/cli/test/utils.ts | 18 +++ 2 files changed, 210 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 0a60a20..e29a1b6 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -3,7 +3,7 @@ import { consola } from 'consola' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_DELIMITER, encode } from '../../toon/src' import { version } from '../package.json' with { type: 'json' } -import { createCliTestContext, runCli } from './utils' +import { createCliTestContext, mockStdin, runCli } from './utils' describe('toon CLI', () => { beforeEach(() => { @@ -25,6 +25,29 @@ describe('toon CLI', () => { }) describe('encode (JSON → TOON)', () => { + it('encodes JSON from stdin', async () => { + const data = { + title: 'TOON test', + count: 3, + nested: { ok: true }, + } + const cleanup = mockStdin(JSON.stringify(data)) + + const stdout: string[] = [] + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await runCli() + expect(stdout).toHaveLength(1) + expect(stdout[0]).toBe(encode(data)) + } + finally { + cleanup() + } + }) + it('encodes a JSON file into a TOON file', async () => { const data = { title: 'TOON test', @@ -61,7 +84,7 @@ describe('toon CLI', () => { }) const stdout: string[] = [] - const logSpy = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { stdout.push(String(message ?? '')) }) @@ -72,7 +95,26 @@ describe('toon CLI', () => { expect(stdout[0]).toBe(encode(data)) } finally { - logSpy.mockRestore() + await context.cleanup() + } + }) + + it('encodes JSON from stdin to output file', async () => { + const data = { key: 'value' } + const context = await createCliTestContext({}) + const cleanup = mockStdin(JSON.stringify(data)) + + const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined) + + try { + await context.run(['--output', 'output.toon']) + + const output = await context.read('output.toon') + expect(output).toBe(encode(data)) + expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.toon/)) + } + finally { + cleanup() await context.cleanup() } }) @@ -102,6 +144,153 @@ describe('toon CLI', () => { await context.cleanup() } }) + + it('decodes TOON from stdin', async () => { + const data = { items: ['a', 'b'], count: 2 } + const toonInput = encode(data) + + const cleanup = mockStdin(toonInput) + + const stdout: string[] = [] + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await runCli({ rawArgs: ['--decode'] }) + expect(stdout).toHaveLength(1) + const result = JSON.parse(stdout?.at(0) ?? '') + expect(result).toEqual(data) + } + finally { + cleanup() + } + }) + + it('decodes TOON from stdin to output file', async () => { + const data = { name: 'test', values: [1, 2, 3] } + const toonInput = encode(data) + const context = await createCliTestContext({}) + const cleanup = mockStdin(toonInput) + + const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined) + + try { + await context.run(['--decode', '--output', 'output.json']) + + const output = await context.read('output.json') + expect(JSON.parse(output)).toEqual(data) + expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.json/)) + } + finally { + cleanup() + await context.cleanup() + } + }) + }) + + describe('stdin edge cases', () => { + it('handles invalid JSON from stdin', async () => { + const cleanup = mockStdin('{ invalid json }') + + const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined) + const exitSpy = vi.mocked(process.exit) + + try { + await runCli({ rawArgs: [] }) + + expect(exitSpy).toHaveBeenCalledWith(1) + expect(consolaError).toHaveBeenCalled() + } + finally { + cleanup() + } + }) + + it('handles invalid TOON from stdin', async () => { + const cleanup = mockStdin('key: "unterminated string') + + const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined) + const exitSpy = vi.mocked(process.exit) + + try { + await runCli({ rawArgs: ['--decode'] }) + + expect(exitSpy).toHaveBeenCalledWith(1) + expect(consolaError).toHaveBeenCalled() + } + finally { + cleanup() + } + }) + }) + + describe('stdin with options', () => { + it('encodes JSON from stdin with custom delimiter', async () => { + const data = { items: [1, 2, 3] } + const cleanup = mockStdin(JSON.stringify(data)) + + const stdout: string[] = [] + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await runCli({ rawArgs: ['--delimiter', '|'] }) + + expect(stdout).toHaveLength(1) + expect(stdout[0]).toBe(encode(data, { delimiter: '|' })) + } + finally { + cleanup() + } + }) + + it('encodes JSON from stdin with custom indent', async () => { + const data = { + nested: { + deep: { value: 1 }, + }, + } + const cleanup = mockStdin(JSON.stringify(data)) + + const stdout: string[] = [] + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await runCli({ rawArgs: ['--indent', '4'] }) + + expect(stdout).toHaveLength(1) + expect(stdout[0]).toBe(encode(data, { indent: 4 })) + } + finally { + cleanup() + } + }) + + it('decodes TOON from stdin with --no-strict', async () => { + const data = { test: true } + const toonInput = encode(data) + const cleanup = mockStdin(toonInput) + + const stdout: string[] = [] + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await runCli({ rawArgs: ['--decode', '--no-strict'] }) + + expect(stdout).toHaveLength(1) + const result = JSON.parse(stdout?.at(0) ?? '') + expect(result).toEqual(data) + } + finally { + cleanup() + } + }) }) describe('error handling', () => { diff --git a/packages/cli/test/utils.ts b/packages/cli/test/utils.ts index eff4aa4..b6f97ae 100644 --- a/packages/cli/test/utils.ts +++ b/packages/cli/test/utils.ts @@ -2,6 +2,7 @@ import * as fsp from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' +import { Readable } from 'node:stream' import { runMain } from 'citty' import { mainCommand } from '../src/index' @@ -76,3 +77,20 @@ async function writeFiles(baseDir: string, files: FileRecord): Promise { }), ) } + +export function mockStdin(input: string): () => void { + const mockStream = Readable.from([input]) + + const originalStdin = process.stdin + Object.defineProperty(process, 'stdin', { + value: mockStream, + writable: true, + }) + + return () => { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + }) + } +}