chore: import upstream snapshot with attribution
CI / ci (push) Has been cancelled
Deploy Docs / Deploy Docs (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:56 +08:00
commit bde7ea0d58
129 changed files with 28635 additions and 0 deletions
+78
View File
@@ -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'
+ ' ^',
)
})
})
+832
View File
@@ -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()
}
})
})
})
+423
View File
@@ -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('')
}
+96
View File
@@ -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,
})
}
}