From af298537a48d767ff22e7b99254b7bb52048810e Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Tue, 4 Nov 2025 07:45:50 +0100 Subject: [PATCH] test(cli): add basic test suite --- packages/cli/package.json | 3 +- packages/cli/src/cli-entry.ts | 4 + packages/cli/src/index.ts | 53 +++++++++++++- packages/cli/test/index.test.ts | 126 ++++++++++++++++++++++++++++++++ packages/cli/test/utils.ts | 78 ++++++++++++++++++++ packages/cli/tsdown.config.ts | 4 +- 6 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/cli-entry.ts create mode 100644 packages/cli/test/index.test.ts create mode 100644 packages/cli/test/utils.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 151aed0..e058f29 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,7 +31,8 @@ ], "scripts": { "dev": "tsx ./src/index.ts", - "build": "tsdown" + "build": "tsdown", + "test": "vitest" }, "dependencies": { "citty": "^0.1.6", diff --git a/packages/cli/src/cli-entry.ts b/packages/cli/src/cli-entry.ts new file mode 100644 index 0000000..957e692 --- /dev/null +++ b/packages/cli/src/cli-entry.ts @@ -0,0 +1,4 @@ +import { runMain } from 'citty' +import { mainCommand } from '.' + +runMain(mainCommand) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f7a25df..64421b8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,15 +1,62 @@ +import type { CommandDef } from 'citty' import type { Delimiter } from '../../toon/src' import type { InputSource } from './types' import * as path from 'node:path' import process from 'node:process' -import { defineCommand, runMain } from 'citty' +import { defineCommand } from 'citty' import { consola } from 'consola' import { name, version } from '../../toon/package.json' with { type: 'json' } import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src' import { decodeToJson, encodeToToon } from './conversion' import { detectMode } from './utils' -const main = defineCommand({ +export const mainCommand: CommandDef<{ + input: { + type: 'positional' + description: string + required: false + } + output: { + type: 'string' + description: string + alias: string + } + encode: { + type: 'boolean' + description: string + alias: string + } + decode: { + type: 'boolean' + description: string + alias: string + } + delimiter: { + type: 'string' + description: string + default: string + } + indent: { + type: 'string' + description: string + default: string + } + lengthMarker: { + type: 'boolean' + description: string + default: false + } + strict: { + type: 'boolean' + description: string + default: true + } + stats: { + type: 'boolean' + description: string + default: false + } +}> = defineCommand({ meta: { name, description: 'TOON CLI — Convert between JSON and TOON formats', @@ -110,5 +157,3 @@ const main = defineCommand({ } }, }) - -runMain(main) diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts new file mode 100644 index 0000000..bdae427 --- /dev/null +++ b/packages/cli/test/index.test.ts @@ -0,0 +1,126 @@ +import process from 'node:process' +import { consola } from 'consola' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { version } from '../../toon/package.json' with { type: 'json' } +import { DEFAULT_DELIMITER, encode } from '../../toon/src' +import { createCliTestContext, runCli } from './utils' + +describe('toon CLI', () => { + beforeEach(() => { + vi.spyOn(process, 'exit').mockImplementation(() => 0 as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('prints the version when using --version', async () => { + const consolaLog = vi.spyOn(consola, 'log').mockImplementation(() => undefined) + const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined) + + await runCli({ rawArgs: ['--version'] }) + + expect(consolaLog).toHaveBeenCalledWith(version) + expect(consolaError).not.toHaveBeenCalled() + }) + + it('encodes a JSON file into a TOON file', async () => { + const data = { + title: 'TOON test', + count: 3, + nested: { ok: true }, + } + const context = await createCliTestContext({ + 'input.json': JSON.stringify(data, undefined, 2), + }) + + const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined) + + try { + await context.run(['input.json', '--output', 'output.toon']) + + const output = await context.read('output.toon') + const expected = encode(data, { + delimiter: DEFAULT_DELIMITER, + indent: 2, + lengthMarker: false, + }) + + expect(output).toBe(expected) + expect(consolaSuccess).toHaveBeenCalledWith('Encoded `input.json` → `output.toon`') + } + finally { + await context.cleanup() + } + }) + + it('decodes a TOON file into a JSON file', async () => { + const data = { + items: ['alpha', 'beta'], + meta: { done: false }, + } + const toonInput = encode(data) + const context = await createCliTestContext({ + 'input.toon': toonInput, + }) + + const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined) + + try { + await context.run(['input.toon', '--output', 'output.json']) + + const output = await context.read('output.json') + expect(JSON.parse(output)).toEqual(data) + expect(consolaSuccess).toHaveBeenCalledWith('Decoded `input.toon` → `output.json`') + } + finally { + await context.cleanup() + } + }) + + it('writes encoded TOON to stdout when no output file is provided', async () => { + const data = { ok: true } + const context = await createCliTestContext({ + 'input.json': JSON.stringify(data), + }) + + const stdout: string[] = [] + const logSpy = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + stdout.push(String(message ?? '')) + }) + + try { + await context.run(['input.json']) + + expect(stdout).toHaveLength(1) + expect(stdout[0]).toBe(encode(data)) + } + finally { + logSpy.mockRestore() + await context.cleanup() + } + }) + + it('throws on an invalid delimiter argument', async () => { + const context = await createCliTestContext({ + 'input.json': JSON.stringify({ value: 1 }), + }) + + const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined) + + try { + await expect(context.run(['input.json', '--delimiter', ';'])).resolves.toBeUndefined() + + const exitMock = vi.mocked(process.exit) + expect(exitMock).toHaveBeenCalledWith(1) + + const errorCall = consolaError.mock.calls.at(0) + expect(errorCall).toBeDefined() + const [error] = errorCall! + expect(error.message).toContain('Invalid delimiter') + } + finally { + await context.cleanup() + } + }) +}) diff --git a/packages/cli/test/utils.ts b/packages/cli/test/utils.ts new file mode 100644 index 0000000..eff4aa4 --- /dev/null +++ b/packages/cli/test/utils.ts @@ -0,0 +1,78 @@ +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 { runMain } from 'citty' +import { mainCommand } from '../src/index' + +interface FileRecord { + [relativePath: string]: string +} + +export function runCli(options?: Parameters[1]): Promise { + return runMain(mainCommand, options) +} + +export interface CliTestContext { + readonly dir: string + run: (args?: string[]) => Promise + read: (relativePath: string) => Promise + write: (relativePath: string, contents: string) => Promise + resolve: (relativePath: string) => string + cleanup: () => Promise +} + +const TEMP_PREFIX = path.join(os.tmpdir(), 'toon-cli-test-') + +export async function createCliTestContext(initialFiles: FileRecord = {}): Promise { + const dir = await fsp.mkdtemp(TEMP_PREFIX) + await writeFiles(dir, initialFiles) + + async function run(args: string[] = []): Promise { + const previousCwd = process.cwd() + process.chdir(dir) + try { + await runCli({ rawArgs: args }) + } + finally { + process.chdir(previousCwd) + } + } + + function resolvePath(relativePath: string): string { + return path.join(dir, relativePath) + } + + async function read(relativePath: string): Promise { + return fsp.readFile(resolvePath(relativePath), 'utf8') + } + + async function write(relativePath: string, contents: string): Promise { + const targetPath = resolvePath(relativePath) + await fsp.mkdir(path.dirname(targetPath), { recursive: true }) + await fsp.writeFile(targetPath, contents, 'utf8') + } + + async function cleanup(): Promise { + await fsp.rm(dir, { recursive: true, force: true }) + } + + return { + dir, + run, + read, + write, + resolve: resolvePath, + cleanup, + } +} + +async function writeFiles(baseDir: string, files: FileRecord): Promise { + await Promise.all( + Object.entries(files).map(async ([relativePath, contents]) => { + const filePath = path.join(baseDir, relativePath) + await fsp.mkdir(path.dirname(filePath), { recursive: true }) + await fsp.writeFile(filePath, contents, 'utf8') + }), + ) +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b817157..b197da6 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -2,7 +2,9 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config' import { defineConfig } from 'tsdown/config' const config: UserConfig | UserConfigFn = defineConfig({ - entry: 'src/index.ts', + entry: { + index: 'src/cli-entry.ts', + }, dts: true, })