import { describe, expect, it, vi } from 'vitest' import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson' describe('encodeNdjson', () => { it('encodes an object as a JSON line ending with newline', () => { const result = encodeNdjson({ type: 'hello', version: 1 }) expect(result).toBe('{"type":"hello","version":1}\n') }) it('encodes nested objects', () => { const msg = { id: 'req-1', type: 'write', payload: { sessionId: 'abc', data: 'ls\n' } } const result = encodeNdjson(msg) expect(result.endsWith('\n')).toBe(true) expect(JSON.parse(result.trim())).toEqual(msg) }) }) describe('createNdjsonParser', () => { it('exports a bounded default line size', () => { expect(NDJSON_MAX_LINE_BYTES).toBe(16 * 1024 * 1024) }) it('parses a single complete message', () => { const onMessage = vi.fn() const onError = vi.fn() const parser = createNdjsonParser(onMessage, onError) parser.feed('{"type":"hello"}\n') expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ type: 'hello' }) expect(onError).not.toHaveBeenCalled() }) it('parses multiple messages in a single chunk', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) parser.feed('{"a":1}\n{"b":2}\n{"c":3}\n') expect(onMessage).toHaveBeenCalledTimes(3) expect(onMessage).toHaveBeenNthCalledWith(1, { a: 1 }) expect(onMessage).toHaveBeenNthCalledWith(2, { b: 2 }) expect(onMessage).toHaveBeenNthCalledWith(3, { c: 3 }) }) it('handles messages split across multiple chunks', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) parser.feed('{"type":"hel') expect(onMessage).not.toHaveBeenCalled() parser.feed('lo","version":1}\n') expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ type: 'hello', version: 1 }) }) it('handles a chunk that ends mid-line followed by more data', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) parser.feed('{"id":"1"}\n{"id":') expect(onMessage).toHaveBeenCalledOnce() parser.feed('"2"}\n') expect(onMessage).toHaveBeenCalledTimes(2) expect(onMessage).toHaveBeenNthCalledWith(2, { id: '2' }) }) it('ignores empty lines', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) parser.feed('\n\n{"ok":true}\n\n') expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ ok: true }) }) it('calls onError for malformed JSON', () => { const onMessage = vi.fn() const onError = vi.fn() const parser = createNdjsonParser(onMessage, onError) parser.feed('not json\n') expect(onMessage).not.toHaveBeenCalled() expect(onError).toHaveBeenCalledOnce() expect(onError.mock.calls[0][0]).toBeInstanceOf(Error) }) it('recovers after malformed JSON and parses next line', () => { const onMessage = vi.fn() const onError = vi.fn() const parser = createNdjsonParser(onMessage, onError) parser.feed('bad\n{"good":true}\n') expect(onError).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ good: true }) }) it('drops oversized complete lines and parses following messages', () => { const onMessage = vi.fn() const onError = vi.fn() const parser = createNdjsonParser(onMessage, onError, { maxLineBytes: 24 }) parser.feed(`${'x'.repeat(25)}\n{"good":true}\n`) expect(onError).toHaveBeenCalledOnce() expect(onError.mock.calls[0][0].message).toContain('NDJSON line exceeds max 24 bytes') expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ good: true }) }) it('discards oversized partial lines until the next delimiter', () => { const onMessage = vi.fn() const onError = vi.fn() const parser = createNdjsonParser(onMessage, onError, { maxLineBytes: 24 }) parser.feed('{"too":') parser.feed(`"${'x'.repeat(24)}"}`) parser.feed('\n{"fresh":true}\n') expect(onError).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ fresh: true }) }) it('handles messages with embedded newlines in strings', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) // JSON.stringify escapes newlines as \n (two chars), so the actual // newline delimiter is still unambiguous. const msg = { data: 'line1\nline2' } parser.feed(`${JSON.stringify(msg)}\n`) expect(onMessage).toHaveBeenCalledWith(msg) }) it('resets buffer state on reset()', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) parser.feed('{"partial":') parser.reset() parser.feed('{"fresh":true}\n') expect(onMessage).toHaveBeenCalledOnce() expect(onMessage).toHaveBeenCalledWith({ fresh: true }) }) })