Skip to content

Commit af29853

Browse files
test(cli): add basic test suite
1 parent 8f0156c commit af29853

File tree

6 files changed

+262
-6
lines changed

6 files changed

+262
-6
lines changed

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
],
3232
"scripts": {
3333
"dev": "tsx ./src/index.ts",
34-
"build": "tsdown"
34+
"build": "tsdown",
35+
"test": "vitest"
3536
},
3637
"dependencies": {
3738
"citty": "^0.1.6",

packages/cli/src/cli-entry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { runMain } from 'citty'
2+
import { mainCommand } from '.'
3+
4+
runMain(mainCommand)

packages/cli/src/index.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,62 @@
1+
import type { CommandDef } from 'citty'
12
import type { Delimiter } from '../../toon/src'
23
import type { InputSource } from './types'
34
import * as path from 'node:path'
45
import process from 'node:process'
5-
import { defineCommand, runMain } from 'citty'
6+
import { defineCommand } from 'citty'
67
import { consola } from 'consola'
78
import { name, version } from '../../toon/package.json' with { type: 'json' }
89
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src'
910
import { decodeToJson, encodeToToon } from './conversion'
1011
import { detectMode } from './utils'
1112

12-
const main = defineCommand({
13+
export const mainCommand: CommandDef<{
14+
input: {
15+
type: 'positional'
16+
description: string
17+
required: false
18+
}
19+
output: {
20+
type: 'string'
21+
description: string
22+
alias: string
23+
}
24+
encode: {
25+
type: 'boolean'
26+
description: string
27+
alias: string
28+
}
29+
decode: {
30+
type: 'boolean'
31+
description: string
32+
alias: string
33+
}
34+
delimiter: {
35+
type: 'string'
36+
description: string
37+
default: string
38+
}
39+
indent: {
40+
type: 'string'
41+
description: string
42+
default: string
43+
}
44+
lengthMarker: {
45+
type: 'boolean'
46+
description: string
47+
default: false
48+
}
49+
strict: {
50+
type: 'boolean'
51+
description: string
52+
default: true
53+
}
54+
stats: {
55+
type: 'boolean'
56+
description: string
57+
default: false
58+
}
59+
}> = defineCommand({
1360
meta: {
1461
name,
1562
description: 'TOON CLI — Convert between JSON and TOON formats',
@@ -110,5 +157,3 @@ const main = defineCommand({
110157
}
111158
},
112159
})
113-
114-
runMain(main)

packages/cli/test/index.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import process from 'node:process'
2+
import { consola } from 'consola'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { version } from '../../toon/package.json' with { type: 'json' }
5+
import { DEFAULT_DELIMITER, encode } from '../../toon/src'
6+
import { createCliTestContext, runCli } from './utils'
7+
8+
describe('toon CLI', () => {
9+
beforeEach(() => {
10+
vi.spyOn(process, 'exit').mockImplementation(() => 0 as never)
11+
})
12+
13+
afterEach(() => {
14+
vi.restoreAllMocks()
15+
})
16+
17+
it('prints the version when using --version', async () => {
18+
const consolaLog = vi.spyOn(consola, 'log').mockImplementation(() => undefined)
19+
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
20+
21+
await runCli({ rawArgs: ['--version'] })
22+
23+
expect(consolaLog).toHaveBeenCalledWith(version)
24+
expect(consolaError).not.toHaveBeenCalled()
25+
})
26+
27+
it('encodes a JSON file into a TOON file', async () => {
28+
const data = {
29+
title: 'TOON test',
30+
count: 3,
31+
nested: { ok: true },
32+
}
33+
const context = await createCliTestContext({
34+
'input.json': JSON.stringify(data, undefined, 2),
35+
})
36+
37+
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
38+
39+
try {
40+
await context.run(['input.json', '--output', 'output.toon'])
41+
42+
const output = await context.read('output.toon')
43+
const expected = encode(data, {
44+
delimiter: DEFAULT_DELIMITER,
45+
indent: 2,
46+
lengthMarker: false,
47+
})
48+
49+
expect(output).toBe(expected)
50+
expect(consolaSuccess).toHaveBeenCalledWith('Encoded `input.json` → `output.toon`')
51+
}
52+
finally {
53+
await context.cleanup()
54+
}
55+
})
56+
57+
it('decodes a TOON file into a JSON file', async () => {
58+
const data = {
59+
items: ['alpha', 'beta'],
60+
meta: { done: false },
61+
}
62+
const toonInput = encode(data)
63+
const context = await createCliTestContext({
64+
'input.toon': toonInput,
65+
})
66+
67+
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
68+
69+
try {
70+
await context.run(['input.toon', '--output', 'output.json'])
71+
72+
const output = await context.read('output.json')
73+
expect(JSON.parse(output)).toEqual(data)
74+
expect(consolaSuccess).toHaveBeenCalledWith('Decoded `input.toon` → `output.json`')
75+
}
76+
finally {
77+
await context.cleanup()
78+
}
79+
})
80+
81+
it('writes encoded TOON to stdout when no output file is provided', async () => {
82+
const data = { ok: true }
83+
const context = await createCliTestContext({
84+
'input.json': JSON.stringify(data),
85+
})
86+
87+
const stdout: string[] = []
88+
const logSpy = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => {
89+
stdout.push(String(message ?? ''))
90+
})
91+
92+
try {
93+
await context.run(['input.json'])
94+
95+
expect(stdout).toHaveLength(1)
96+
expect(stdout[0]).toBe(encode(data))
97+
}
98+
finally {
99+
logSpy.mockRestore()
100+
await context.cleanup()
101+
}
102+
})
103+
104+
it('throws on an invalid delimiter argument', async () => {
105+
const context = await createCliTestContext({
106+
'input.json': JSON.stringify({ value: 1 }),
107+
})
108+
109+
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
110+
111+
try {
112+
await expect(context.run(['input.json', '--delimiter', ';'])).resolves.toBeUndefined()
113+
114+
const exitMock = vi.mocked(process.exit)
115+
expect(exitMock).toHaveBeenCalledWith(1)
116+
117+
const errorCall = consolaError.mock.calls.at(0)
118+
expect(errorCall).toBeDefined()
119+
const [error] = errorCall!
120+
expect(error.message).toContain('Invalid delimiter')
121+
}
122+
finally {
123+
await context.cleanup()
124+
}
125+
})
126+
})

packages/cli/test/utils.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as fsp from 'node:fs/promises'
2+
import * as os from 'node:os'
3+
import * as path from 'node:path'
4+
import process from 'node:process'
5+
import { runMain } from 'citty'
6+
import { mainCommand } from '../src/index'
7+
8+
interface FileRecord {
9+
[relativePath: string]: string
10+
}
11+
12+
export function runCli(options?: Parameters<typeof runMain>[1]): Promise<void> {
13+
return runMain(mainCommand, options)
14+
}
15+
16+
export interface CliTestContext {
17+
readonly dir: string
18+
run: (args?: string[]) => Promise<void>
19+
read: (relativePath: string) => Promise<string>
20+
write: (relativePath: string, contents: string) => Promise<void>
21+
resolve: (relativePath: string) => string
22+
cleanup: () => Promise<void>
23+
}
24+
25+
const TEMP_PREFIX = path.join(os.tmpdir(), 'toon-cli-test-')
26+
27+
export async function createCliTestContext(initialFiles: FileRecord = {}): Promise<CliTestContext> {
28+
const dir = await fsp.mkdtemp(TEMP_PREFIX)
29+
await writeFiles(dir, initialFiles)
30+
31+
async function run(args: string[] = []): Promise<void> {
32+
const previousCwd = process.cwd()
33+
process.chdir(dir)
34+
try {
35+
await runCli({ rawArgs: args })
36+
}
37+
finally {
38+
process.chdir(previousCwd)
39+
}
40+
}
41+
42+
function resolvePath(relativePath: string): string {
43+
return path.join(dir, relativePath)
44+
}
45+
46+
async function read(relativePath: string): Promise<string> {
47+
return fsp.readFile(resolvePath(relativePath), 'utf8')
48+
}
49+
50+
async function write(relativePath: string, contents: string): Promise<void> {
51+
const targetPath = resolvePath(relativePath)
52+
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
53+
await fsp.writeFile(targetPath, contents, 'utf8')
54+
}
55+
56+
async function cleanup(): Promise<void> {
57+
await fsp.rm(dir, { recursive: true, force: true })
58+
}
59+
60+
return {
61+
dir,
62+
run,
63+
read,
64+
write,
65+
resolve: resolvePath,
66+
cleanup,
67+
}
68+
}
69+
70+
async function writeFiles(baseDir: string, files: FileRecord): Promise<void> {
71+
await Promise.all(
72+
Object.entries(files).map(async ([relativePath, contents]) => {
73+
const filePath = path.join(baseDir, relativePath)
74+
await fsp.mkdir(path.dirname(filePath), { recursive: true })
75+
await fsp.writeFile(filePath, contents, 'utf8')
76+
}),
77+
)
78+
}

packages/cli/tsdown.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config'
22
import { defineConfig } from 'tsdown/config'
33

44
const config: UserConfig | UserConfigFn = defineConfig({
5-
entry: 'src/index.ts',
5+
entry: {
6+
index: 'src/cli-entry.ts',
7+
},
68
dts: true,
79
})
810

0 commit comments

Comments
 (0)