mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
test(cli): add basic test suite
This commit is contained in:
@@ -31,7 +31,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx ./src/index.ts",
|
"dev": "tsx ./src/index.ts",
|
||||||
"build": "tsdown"
|
"build": "tsdown",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
|
|||||||
4
packages/cli/src/cli-entry.ts
Normal file
4
packages/cli/src/cli-entry.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { runMain } from 'citty'
|
||||||
|
import { mainCommand } from '.'
|
||||||
|
|
||||||
|
runMain(mainCommand)
|
||||||
@@ -1,15 +1,62 @@
|
|||||||
|
import type { CommandDef } from 'citty'
|
||||||
import type { Delimiter } from '../../toon/src'
|
import type { Delimiter } from '../../toon/src'
|
||||||
import type { InputSource } from './types'
|
import type { InputSource } from './types'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { defineCommand, runMain } from 'citty'
|
import { defineCommand } from 'citty'
|
||||||
import { consola } from 'consola'
|
import { consola } from 'consola'
|
||||||
import { name, version } from '../../toon/package.json' with { type: 'json' }
|
import { name, version } from '../../toon/package.json' with { type: 'json' }
|
||||||
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src'
|
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src'
|
||||||
import { decodeToJson, encodeToToon } from './conversion'
|
import { decodeToJson, encodeToToon } from './conversion'
|
||||||
import { detectMode } from './utils'
|
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: {
|
meta: {
|
||||||
name,
|
name,
|
||||||
description: 'TOON CLI — Convert between JSON and TOON formats',
|
description: 'TOON CLI — Convert between JSON and TOON formats',
|
||||||
@@ -110,5 +157,3 @@ const main = defineCommand({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
runMain(main)
|
|
||||||
|
|||||||
126
packages/cli/test/index.test.ts
Normal file
126
packages/cli/test/index.test.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
78
packages/cli/test/utils.ts
Normal file
78
packages/cli/test/utils.ts
Normal file
@@ -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<typeof runMain>[1]): Promise<void> {
|
||||||
|
return runMain(mainCommand, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliTestContext {
|
||||||
|
readonly dir: string
|
||||||
|
run: (args?: string[]) => Promise<void>
|
||||||
|
read: (relativePath: string) => Promise<string>
|
||||||
|
write: (relativePath: string, contents: string) => Promise<void>
|
||||||
|
resolve: (relativePath: string) => string
|
||||||
|
cleanup: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMP_PREFIX = path.join(os.tmpdir(), 'toon-cli-test-')
|
||||||
|
|
||||||
|
export async function createCliTestContext(initialFiles: FileRecord = {}): Promise<CliTestContext> {
|
||||||
|
const dir = await fsp.mkdtemp(TEMP_PREFIX)
|
||||||
|
await writeFiles(dir, initialFiles)
|
||||||
|
|
||||||
|
async function run(args: string[] = []): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
return fsp.readFile(resolvePath(relativePath), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(relativePath: string, contents: string): Promise<void> {
|
||||||
|
const targetPath = resolvePath(relativePath)
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||||
|
await fsp.writeFile(targetPath, contents, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
await fsp.rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
run,
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
resolve: resolvePath,
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFiles(baseDir: string, files: FileRecord): Promise<void> {
|
||||||
|
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')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config'
|
|||||||
import { defineConfig } from 'tsdown/config'
|
import { defineConfig } from 'tsdown/config'
|
||||||
|
|
||||||
const config: UserConfig | UserConfigFn = defineConfig({
|
const config: UserConfig | UserConfigFn = defineConfig({
|
||||||
entry: 'src/index.ts',
|
entry: {
|
||||||
|
index: 'src/cli-entry.ts',
|
||||||
|
},
|
||||||
dts: true,
|
dts: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user