chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ToonDecodeError } from '../../toon/src/index'
|
||||
import { formatError } from '../src/format-error'
|
||||
|
||||
describe('formatError', () => {
|
||||
it('renders a decode error with line and source as a header, source line, and caret', () => {
|
||||
const error = new ToonDecodeError(
|
||||
'Tabs are not allowed in indentation in strict mode',
|
||||
{ line: 2, source: '\tb: 1' },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe(
|
||||
'Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode\n'
|
||||
+ '\n'
|
||||
+ ' 2 | →b: 1\n'
|
||||
+ ' ^',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a decode error without source as a header only', () => {
|
||||
const error = new ToonDecodeError('Something went wrong', { line: 5 })
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe('Failed to decode TOON at line 5: Something went wrong')
|
||||
})
|
||||
|
||||
it('appends the cause chain under verbose mode', () => {
|
||||
const cause = new SyntaxError('Unterminated string: missing closing quote')
|
||||
const error = new ToonDecodeError(
|
||||
'Unterminated string: missing closing quote',
|
||||
{ line: 2, source: 'greeting: "hello', cause },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: true })
|
||||
|
||||
expect(output).toContain('Failed to decode TOON at line 2:')
|
||||
expect(output).toContain(' 2 | greeting: "hello')
|
||||
expect(output).toContain('Caused by: SyntaxError: Unterminated string: missing closing quote')
|
||||
})
|
||||
|
||||
it('appends the stack trace under verbose mode and omits it otherwise', () => {
|
||||
const error = new ToonDecodeError('Boom', { line: 1, source: 'x' })
|
||||
error.stack = 'ToonDecodeError: Line 1: Boom\n at fakeFrame (file.ts:1:1)'
|
||||
|
||||
const verbose = formatError(error, { isVerbose: true })
|
||||
const quiet = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(verbose).toContain('at fakeFrame (file.ts:1:1)')
|
||||
expect(quiet).not.toContain('at fakeFrame')
|
||||
})
|
||||
|
||||
it('renders a generic Error as its message only when not verbose', () => {
|
||||
const error = new Error('something went wrong')
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe('Error: something went wrong')
|
||||
})
|
||||
|
||||
it('places the caret under the first non-whitespace character of the source line', () => {
|
||||
const error = new ToonDecodeError(
|
||||
'Indentation must be exact multiple of 2, but found 3 spaces',
|
||||
{ line: 2, source: ' b: 1' },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe(
|
||||
'Failed to decode TOON at line 2: Indentation must be exact multiple of 2, but found 3 spaces\n'
|
||||
+ '\n'
|
||||
+ ' 2 | b: 1\n'
|
||||
+ ' ^',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,832 @@
|
||||
import process from 'node:process'
|
||||
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, mockStdin, runCli } from './utils'
|
||||
|
||||
describe('toon CLI', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => 0 as never)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('version', () => {
|
||||
it('prints the version when using --version', async () => {
|
||||
const consoleLog = vi.mocked(console.log)
|
||||
|
||||
await runCli({ rawArgs: ['--version'] })
|
||||
|
||||
expect(consoleLog).toHaveBeenCalledWith(version)
|
||||
})
|
||||
})
|
||||
|
||||
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 writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli()
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data)}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
expect(output).toBe(expected)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes to stdout when output not specified', async () => {
|
||||
const data = { ok: true }
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json'])
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data)}\n`)
|
||||
}
|
||||
finally {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('decode (TOON → JSON)', () => {
|
||||
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(expect.stringMatching(/Decoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
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 writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
const fullOutput = writeChunks.join('')
|
||||
// Remove trailing newline before parsing
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
const result = JSON.parse(jsonOutput)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a TOON decode error with line context, source, and caret', async () => {
|
||||
const cleanup = mockStdin('a:\n\tb: 1\n')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [rendered] = errorCall!
|
||||
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
|
||||
expect(rendered).toEqual(expect.stringContaining(' 2 | →b: 1'))
|
||||
expect(rendered).toEqual(expect.stringContaining(' ^'))
|
||||
expect(rendered).not.toEqual(expect.stringMatching(/^\s+at \S+/m))
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes the stack trace when --verbose is passed', async () => {
|
||||
const cleanup = mockStdin('a:\n\tb: 1\n')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode', '--verbose'] })
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [rendered] = errorCall!
|
||||
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
|
||||
expect(rendered).toEqual(expect.stringMatching(/at \S+/))
|
||||
}
|
||||
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 writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--delimiter', '|'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data, { delimiter: '|' })}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes JSON from stdin with custom indent', async () => {
|
||||
const data = {
|
||||
nested: {
|
||||
deep: { value: 1 },
|
||||
},
|
||||
}
|
||||
const cleanup = mockStdin(JSON.stringify(data))
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--indent', '4'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data, { indent: 4 })}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes TOON from stdin with --no-strict', async () => {
|
||||
const data = { test: true }
|
||||
const toonInput = encode(data)
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode', '--no-strict'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
// Remove trailing newline before parsing
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
const result = JSON.parse(jsonOutput)
|
||||
expect(result).toEqual(data)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('encode options', () => {
|
||||
it('encodes with --keyFolding safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
items: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'safe', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
const expected = encode(data, { keyFolding: 'safe' })
|
||||
|
||||
expect(output).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes with --flattenDepth', async () => {
|
||||
const data = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'safe', '--flattenDepth', '2', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
const expected = encode(data, { keyFolding: 'safe', flattenDepth: 2 })
|
||||
|
||||
expect(output).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('decode options', () => {
|
||||
it('decodes with --expandPaths safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
items: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}
|
||||
const toonInput = encode(data, { keyFolding: 'safe' })
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': toonInput,
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--expandPaths', 'safe', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes with --indent for JSON formatting', async () => {
|
||||
const data = {
|
||||
a: 1,
|
||||
b: [2, 3],
|
||||
c: { nested: true },
|
||||
}
|
||||
const toonInput = encode(data, { indent: 4 })
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': toonInput,
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--indent', '4', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(output).toContain(' ') // Should have 4-space indentation
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive number', async () => {
|
||||
const toonInput = '42'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe('42\n')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive string', async () => {
|
||||
const toonInput = '"Hello World"'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
expect(JSON.parse(jsonOutput)).toBe('Hello World')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive boolean', async () => {
|
||||
const toonInput = 'true'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe('true\n')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming output', () => {
|
||||
it('streams large JSON to TOON file with identical output', async () => {
|
||||
const data = {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
value: Math.random(),
|
||||
})),
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'large-input.json': JSON.stringify(data, undefined, 2),
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['large-input.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
// Verify streaming produces identical output to `encode()`
|
||||
const expected = encode(data, {
|
||||
delimiter: DEFAULT_DELIMITER,
|
||||
indent: 2,
|
||||
})
|
||||
|
||||
expect(output).toBe(expected)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('streams large TOON to JSON file with streaming decode', async () => {
|
||||
const data = {
|
||||
records: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
title: `Record ${i}`,
|
||||
score: Math.random() * 100,
|
||||
})),
|
||||
}
|
||||
|
||||
const toonContent = encode(data, {
|
||||
delimiter: DEFAULT_DELIMITER,
|
||||
indent: 2,
|
||||
})
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'large-input.toon': toonContent,
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['large-input.toon', '--decode', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('streams to stdout using process.stdout.write', async () => {
|
||||
const data = {
|
||||
users: [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const writeChunks: string[] = []
|
||||
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json'])
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
|
||||
// Verify complete output matches `encode()`
|
||||
const fullOutput = writeChunks.join('')
|
||||
const expected = `${encode(data)}\n`
|
||||
expect(fullOutput).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles empty object streaming correctly', async () => {
|
||||
const data = {}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'empty.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['empty.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
expect(output).toBe(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles single-line output streaming correctly', async () => {
|
||||
const data = { key: 'value' }
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'single.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['single.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
expect(output).toBe(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses non-streaming path when stats are enabled', async () => {
|
||||
const data = {
|
||||
items: [
|
||||
{ id: 1, value: 'test' },
|
||||
{ id: 2, value: 'data' },
|
||||
],
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const consolaInfo = vi.spyOn(consola, 'info').mockImplementation(() => undefined)
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--stats'])
|
||||
|
||||
expect(consolaInfo).toHaveBeenCalledWith(expect.stringMatching(/Token estimates:/))
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Saved.*tokens/))
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('rejects invalid delimiter', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--delimiter', ';'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid delimiter')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid indent value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--indent', 'abc'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid indent value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles missing input file', async () => {
|
||||
const context = await createCliTestContext({})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['nonexistent.json'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
expect(consolaError).toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --keyFolding value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'invalid'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid keyFolding value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --expandPaths value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': 'key: value',
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--expandPaths', 'invalid'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid expandPaths value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --flattenDepth value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--flattenDepth', '-1'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid flattenDepth value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { JsonStreamEvent } from '../../toon/src/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonStreamFromEvents } from '../src/json-from-events'
|
||||
|
||||
describe('jsonStreamFromEvents', () => {
|
||||
describe('primitives', () => {
|
||||
it('converts null event', async () => {
|
||||
const events = [
|
||||
{ type: 'primitive' as const, value: null },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(null))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(null, null, 2))
|
||||
})
|
||||
|
||||
it('converts boolean events', async () => {
|
||||
const eventsTrue = [{ type: 'primitive' as const, value: true }]
|
||||
const eventsFalse = [{ type: 'primitive' as const, value: false }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 0))).toBe(JSON.stringify(true))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFalse), 0))).toBe(JSON.stringify(false))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 2))).toBe(JSON.stringify(true, null, 2))
|
||||
})
|
||||
|
||||
it('converts number events', async () => {
|
||||
const events0 = [{ type: 'primitive' as const, value: 0 }]
|
||||
const events42 = [{ type: 'primitive' as const, value: 42 }]
|
||||
const eventsNeg = [{ type: 'primitive' as const, value: -17 }]
|
||||
const eventsFloat = [{ type: 'primitive' as const, value: 3.14159 }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events0), 0))).toBe(JSON.stringify(0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 0))).toBe(JSON.stringify(42))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsNeg), 0))).toBe(JSON.stringify(-17))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFloat), 0))).toBe(JSON.stringify(3.14159))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 2))).toBe(JSON.stringify(42, null, 2))
|
||||
})
|
||||
|
||||
it('converts string events', async () => {
|
||||
const eventsEmpty = [{ type: 'primitive' as const, value: '' }]
|
||||
const eventsHello = [{ type: 'primitive' as const, value: 'hello' }]
|
||||
const eventsQuotes = [{ type: 'primitive' as const, value: 'with "quotes"' }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsEmpty), 0))).toBe(JSON.stringify(''))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsHello), 0))).toBe(JSON.stringify('hello'))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsQuotes), 0))).toBe(JSON.stringify('with "quotes"'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty containers', () => {
|
||||
it('converts empty array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 0 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify([], null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify([], null, 2))
|
||||
})
|
||||
|
||||
it('converts empty object events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify({}, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify({}, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays', () => {
|
||||
it('converts simple array events with compact formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 2, 3]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('converts simple array events with pretty formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 2, 3]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts mixed-type array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 5 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 'two' },
|
||||
{ type: 'primitive' as const, value: true },
|
||||
{ type: 'primitive' as const, value: null },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'key' },
|
||||
{ type: 'primitive' as const, value: 'value' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 'two', true, null, { key: 'value' }]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts nested array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'primitive' as const, value: 4 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 5 },
|
||||
{ type: 'primitive' as const, value: 6 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [[1, 2], [3, 4], [5, 6]]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects', () => {
|
||||
it('converts simple object events with compact formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('converts simple object events with pretty formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts object events with mixed value types', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'num' },
|
||||
{ type: 'primitive' as const, value: 42 },
|
||||
{ type: 'key' as const, key: 'str' },
|
||||
{ type: 'primitive' as const, value: 'hello' },
|
||||
{ type: 'key' as const, key: 'bool' },
|
||||
{ type: 'primitive' as const, value: true },
|
||||
{ type: 'key' as const, key: 'nil' },
|
||||
{ type: 'primitive' as const, value: null },
|
||||
{ type: 'key' as const, key: 'arr' },
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
num: 42,
|
||||
str: 'hello',
|
||||
bool: true,
|
||||
nil: null,
|
||||
arr: [1, 2, 3],
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts nested object events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level1' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level2' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level3' },
|
||||
{ type: 'primitive' as const, value: 'deep' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'deep',
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles special characters in keys', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'normal-key' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'key with spaces' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'key:with:colons' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'key' as const, key: 'key"with"quotes' },
|
||||
{ type: 'primitive' as const, value: 4 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
'normal-key': 1,
|
||||
'key with spaces': 2,
|
||||
'key:with:colons': 3,
|
||||
'key"with"quotes': 4,
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex nested structures', () => {
|
||||
it('converts object containing arrays', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'key' as const, key: 'scores' },
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 95 },
|
||||
{ type: 'primitive' as const, value: 87 },
|
||||
{ type: 'primitive' as const, value: 92 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'key' as const, key: 'metadata' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'tags' },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 'math' },
|
||||
{ type: 'primitive' as const, value: 'science' },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
name: 'Alice',
|
||||
scores: [95, 87, 92],
|
||||
metadata: {
|
||||
tags: ['math', 'science'],
|
||||
},
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts array of objects', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Bob' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Charlie' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
{ id: 3, name: 'Charlie' },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation levels', () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: [1, 2], b: { c: 3 } }
|
||||
|
||||
it('handles indent=0 (compact)', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('handles indent=2', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles indent=4', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
|
||||
it('handles indent=8', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 8))).toBe(JSON.stringify(value, null, 8))
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws on mismatched endObject event', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 0 },
|
||||
{ type: 'endObject' as const }, // Wrong closing event
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Mismatched endObject event')
|
||||
})
|
||||
|
||||
it('throws on mismatched endArray event', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'endArray' as const }, // Wrong closing event
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Mismatched endArray event')
|
||||
})
|
||||
|
||||
it('throws on key event outside object context', async () => {
|
||||
const events = [
|
||||
{ type: 'key' as const, key: 'invalid' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Key event outside of object context')
|
||||
})
|
||||
|
||||
it('throws on primitive in object without preceding key', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'primitive' as const, value: 'invalid' }, // No key before primitive
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Primitive event in object without preceding key')
|
||||
})
|
||||
|
||||
it('throws on incomplete event stream', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
// Missing `endObject`
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Incomplete event stream: unclosed objects or arrays')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Converts array of events to async iterable.
|
||||
*/
|
||||
async function* asyncEvents(events: JsonStreamEvent[]): AsyncIterable<JsonStreamEvent> {
|
||||
for (const event of events) {
|
||||
await Promise.resolve()
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins chunks from an async iterable into a single string.
|
||||
*/
|
||||
async function join(iter: AsyncIterable<string>): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of iter) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonStringifyLines } from '../src/json-stringify-stream'
|
||||
|
||||
describe('jsonStringifyLines', () => {
|
||||
describe('primitives', () => {
|
||||
it('stringifies null', () => {
|
||||
expect(join(jsonStringifyLines(null, 0))).toBe(JSON.stringify(null))
|
||||
expect(join(jsonStringifyLines(null, 2))).toBe(JSON.stringify(null, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies booleans', () => {
|
||||
expect(join(jsonStringifyLines(true, 0))).toBe(JSON.stringify(true))
|
||||
expect(join(jsonStringifyLines(false, 0))).toBe(JSON.stringify(false))
|
||||
expect(join(jsonStringifyLines(true, 2))).toBe(JSON.stringify(true, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies numbers', () => {
|
||||
expect(join(jsonStringifyLines(0, 0))).toBe(JSON.stringify(0))
|
||||
expect(join(jsonStringifyLines(42, 0))).toBe(JSON.stringify(42))
|
||||
expect(join(jsonStringifyLines(-17, 0))).toBe(JSON.stringify(-17))
|
||||
expect(join(jsonStringifyLines(3.14159, 0))).toBe(JSON.stringify(3.14159))
|
||||
expect(join(jsonStringifyLines(1e10, 2))).toBe(JSON.stringify(1e10, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies strings', () => {
|
||||
expect(join(jsonStringifyLines('', 0))).toBe(JSON.stringify(''))
|
||||
expect(join(jsonStringifyLines('hello', 0))).toBe(JSON.stringify('hello'))
|
||||
expect(join(jsonStringifyLines('with "quotes"', 0))).toBe(JSON.stringify('with "quotes"'))
|
||||
expect(join(jsonStringifyLines('with\nnewlines', 2))).toBe(JSON.stringify('with\nnewlines', null, 2))
|
||||
expect(join(jsonStringifyLines('with\ttabs', 0))).toBe(JSON.stringify('with\ttabs'))
|
||||
})
|
||||
|
||||
it('converts undefined to null', () => {
|
||||
expect(join(jsonStringifyLines(undefined, 0))).toBe('null')
|
||||
expect(join(jsonStringifyLines(undefined, 2))).toBe('null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty containers', () => {
|
||||
it('stringifies empty arrays', () => {
|
||||
expect(join(jsonStringifyLines([], 0))).toBe(JSON.stringify([], null, 0))
|
||||
expect(join(jsonStringifyLines([], 2))).toBe(JSON.stringify([], null, 2))
|
||||
})
|
||||
|
||||
it('stringifies empty objects', () => {
|
||||
expect(join(jsonStringifyLines({}, 0))).toBe(JSON.stringify({}, null, 0))
|
||||
expect(join(jsonStringifyLines({}, 2))).toBe(JSON.stringify({}, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays', () => {
|
||||
it('stringifies arrays with compact formatting (indent=0)', () => {
|
||||
const value = [1, 2, 3]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('stringifies arrays with pretty formatting (indent=2)', () => {
|
||||
const value = [1, 2, 3]
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies mixed-type arrays', () => {
|
||||
const value = [1, 'two', true, null, { key: 'value' }]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies nested arrays', () => {
|
||||
const value = [[1, 2], [3, 4], [5, 6]]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies deeply nested arrays', () => {
|
||||
const value = [[[1]], [[2]], [[3]]]
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects', () => {
|
||||
it('stringifies simple objects with compact formatting', () => {
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('stringifies simple objects with pretty formatting', () => {
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies objects with mixed value types', () => {
|
||||
const value = {
|
||||
num: 42,
|
||||
str: 'hello',
|
||||
bool: true,
|
||||
nil: null,
|
||||
arr: [1, 2, 3],
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies nested objects', () => {
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'deep',
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('preserves key order', () => {
|
||||
const value = { z: 1, a: 2, m: 3 }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles special characters in keys', () => {
|
||||
const value = {
|
||||
'normal-key': 1,
|
||||
'key with spaces': 2,
|
||||
'key:with:colons': 3,
|
||||
'key"with"quotes': 4,
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex nested structures', () => {
|
||||
it('stringifies objects containing arrays', () => {
|
||||
const value = {
|
||||
name: 'Alice',
|
||||
scores: [95, 87, 92],
|
||||
metadata: {
|
||||
tags: ['math', 'science'],
|
||||
},
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies arrays of objects', () => {
|
||||
const value = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
{ id: 3, name: 'Charlie' },
|
||||
]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies deeply nested mixed structures', () => {
|
||||
const value = {
|
||||
users: [
|
||||
{
|
||||
name: 'Alice',
|
||||
roles: ['admin', 'user'],
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
roles: ['user'],
|
||||
settings: {
|
||||
theme: 'light',
|
||||
notifications: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation levels', () => {
|
||||
const value = { a: [1, 2], b: { c: 3 } }
|
||||
|
||||
it('handles indent=0 (compact)', () => {
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('handles indent=2', () => {
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles indent=4', () => {
|
||||
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
|
||||
it('handles indent=8', () => {
|
||||
expect(join(jsonStringifyLines(value, 8))).toBe(JSON.stringify(value, null, 8))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles arrays with undefined values (converted to null)', () => {
|
||||
const value = [1, undefined, 3]
|
||||
const expected = JSON.stringify(value, null, 2)
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(expected)
|
||||
})
|
||||
|
||||
it('handles single-element arrays', () => {
|
||||
const value = [42]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles single-property objects', () => {
|
||||
const value = { only: 'one' }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles objects with many properties', () => {
|
||||
const value: Record<string, number> = {}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
value[`key${i}`] = i
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles large arrays', () => {
|
||||
const value = Array.from({ length: 1000 }, (_, i) => i)
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Joins chunks from an iterable into a single string.
|
||||
*/
|
||||
function join(iter: Iterable<string>): string {
|
||||
return Array.from(iter).join('')
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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'
|
||||
|
||||
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')
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user