import { describe, it, expect } from '@jest/globals';
import {
parseTextOutput,
parseJsonOutput,
parseJsonlOutput,
extractJsonPath,
} from '../src/providers/cli/output-parsers';
describe('extractJsonPath', () => {
it('should extract nested paths with $. prefix', () => {
const obj = { foo: { bar: { baz: 'hello' } } };
expect(extractJsonPath(obj, '$.foo.bar.baz')).toBe('hello');
});
it('should extract paths without $. prefix', () => {
const obj = { foo: { bar: 'hello' } };
expect(extractJsonPath(obj, 'foo.bar')).toBe('hello');
});
it('should return undefined for missing keys', () => {
const obj = { foo: 'bar' };
expect(extractJsonPath(obj, '$.missing.key')).toBeUndefined();
});
it('should handle array access', () => {
const obj = { items: ['a', 'b', 'c'] };
expect(extractJsonPath(obj, '$.items[1]')).toBe('b');
});
it('should handle null values gracefully', () => {
expect(extractJsonPath(null, '$.foo')).toBeUndefined();
expect(extractJsonPath(undefined, '$.foo')).toBeUndefined();
});
it('should handle nested objects in arrays', () => {
const obj = { data: [{ name: 'first' }, { name: 'second' }] };
expect(extractJsonPath(obj, '$.data[0].name')).toBe('first');
});
it('should return the full object for empty path after $. strip', () => {
const obj = { result: 'test' };
expect(extractJsonPath(obj, '$.result')).toBe('test');
});
it('should handle $[index] syntax for root-level arrays', () => {
const arr = ['a', 'b', 'c'];
expect(extractJsonPath(arr, '$[0]')).toBe('a');
expect(extractJsonPath(arr, '$[2]')).toBe('c');
});
it('should handle out-of-bounds array access', () => {
const obj = { items: ['a'] };
expect(extractJsonPath(obj, '$.items[5]')).toBeUndefined();
});
it('should return full object for empty path', () => {
const obj = { a: 1 };
expect(extractJsonPath(obj, '')).toEqual({ a: 1 });
});
it('should access numeric keys on non-array objects', () => {
const obj = { data: { '0': 'first', '1': 'second' } };
expect(extractJsonPath(obj, '$.data[0]')).toBe('first');
expect(extractJsonPath(obj, '$.data[1]')).toBe('second');
});
it('should return undefined when traversing through a primitive', () => {
// Trying to access a property on a number should return undefined
const obj = { count: 42 };
expect(extractJsonPath(obj, '$.count.foo')).toBeUndefined();
});
it('should handle deeply nested mixed array/object access', () => {
const obj = { a: [{ b: [1, 2, 3] }] };
expect(extractJsonPath(obj, '$.a[0].b[2]')).toBe(3);
});
});
describe('parseTextOutput', () => {
it('should trim whitespace from output', () => {
expect(parseTextOutput(' hello world \n')).toEqual({ content: 'hello world' });
});
it('should handle empty output', () => {
expect(parseTextOutput('')).toEqual({ content: '' });
expect(parseTextOutput(' \n ')).toEqual({ content: '' });
});
it('should handle multiline output', () => {
const output = ' Line 1\nLine 2\nLine 3 ';
expect(parseTextOutput(output)).toEqual({ content: 'Line 1\nLine 2\nLine 3' });
});
});
describe('parseJsonOutput', () => {
it('should extract field via path', () => {
const json = JSON.stringify({ result: 'hello world' });
const result = parseJsonOutput(json, '$.result');
expect(result.content).toBe('hello world');
});
it('should handle missing response path by trying common fields', () => {
const json = JSON.stringify({ result: 'found it' });
const result = parseJsonOutput(json);
expect(result.content).toBe('found it');
});
it('should try "response" field as fallback', () => {
const json = JSON.stringify({ response: 'from response field' });
const result = parseJsonOutput(json);
expect(result.content).toBe('from response field');
});
it('should try "content" field as fallback', () => {
const json = JSON.stringify({ content: 'from content field' });
const result = parseJsonOutput(json);
expect(result.content).toBe('from content field');
});
it('should try "message" field as fallback', () => {
const json = JSON.stringify({ message: 'from message field' });
const result = parseJsonOutput(json);
expect(result.content).toBe('from message field');
});
it('should handle invalid JSON by returning raw text', () => {
const result = parseJsonOutput('not valid json');
expect(result.content).toBe('not valid json');
});
it('should handle empty output', () => {
const result = parseJsonOutput('');
expect(result.content).toBe('');
});
it('should extract usage stats via usage path', () => {
const json = JSON.stringify({
result: 'hello',
usage: {
prompt_tokens: 10,
completion_tokens: 20,
},
});
const result = parseJsonOutput(json, '$.result', '$.usage');
expect(result.content).toBe('hello');
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
});
});
it('should handle usage with alternative field names', () => {
const json = JSON.stringify({
result: 'hello',
stats: {
input_tokens: 5,
output_tokens: 15,
},
});
const result = parseJsonOutput(json, '$.result', '$.stats');
expect(result.usage).toEqual({
promptTokens: 5,
completionTokens: 15,
totalTokens: 20,
});
});
it('should stringify non-string extracted values', () => {
const json = JSON.stringify({ data: { nested: true } });
const result = parseJsonOutput(json, '$.data');
expect(result.content).toBe('{"nested":true}');
});
it('should return full JSON when responsePath points to nonexistent field', () => {
const obj = { result: 'hello', other: 'data' };
const json = JSON.stringify(obj);
const result = parseJsonOutput(json, '$.nonexistent');
// Should fall back to stringifying the full object, not return "undefined"
expect(result.content).toBe(json);
});
it('should return full JSON when responsePath points to null value', () => {
const obj = { result: null, other: 'data' };
const json = JSON.stringify(obj);
const result = parseJsonOutput(json, '$.result');
expect(result.content).toBe(json);
});
it('should handle JSON primitive values without response path', () => {
// JSON.parse('42') gives number 42, not an object
const result = parseJsonOutput('42');
expect(result.content).toBe('42');
const result2 = parseJsonOutput('"just a string"');
expect(result2.content).toBe('just a string');
const result3 = parseJsonOutput('true');
expect(result3.content).toBe('true');
});
it('should handle numeric extracted values', () => {
const json = JSON.stringify({ count: 42 });
const result = parseJsonOutput(json, '$.count');
expect(result.content).toBe('42');
});
it('should handle boolean extracted values', () => {
const json = JSON.stringify({ success: true });
const result = parseJsonOutput(json, '$.success');
expect(result.content).toBe('true');
});
});
describe('parseJsonlOutput', () => {
it('should extract content from last line with result field', () => {
const lines = [
JSON.stringify({ type: 'start', id: '1' }),
JSON.stringify({ type: 'progress', message: 'working' }),
JSON.stringify({ type: 'result', result: 'final answer' }),
].join('\n');
const result = parseJsonlOutput(lines);
expect(result.content).toBe('final answer');
});
it('should extract via custom response path', () => {
const lines = [
JSON.stringify({ event: 'done', data: { answer: 'custom path' } }),
].join('\n');
const result = parseJsonlOutput(lines, '$.data.answer');
expect(result.content).toBe('custom path');
});
it('should handle single-line JSONL', () => {
const line = JSON.stringify({ message: 'single line response' });
const result = parseJsonlOutput(line);
expect(result.content).toBe('single line response');
});
it('should handle empty stream', () => {
const result = parseJsonlOutput('');
expect(result.content).toBe('');
});
it('should skip non-JSON lines', () => {
const lines = 'not json\n' + JSON.stringify({ content: 'valid' }) + '\nalso not json';
const result = parseJsonlOutput(lines);
expect(result.content).toBe('valid');
});
it('should use last matching field when multiple lines have content', () => {
const lines = [
JSON.stringify({ message: 'first' }),
JSON.stringify({ message: 'second' }),
JSON.stringify({ message: 'last' }),
].join('\n');
const result = parseJsonlOutput(lines);
expect(result.content).toBe('last');
});
it('should fall back to raw text when no JSON is found', () => {
const result = parseJsonlOutput('just plain text');
expect(result.content).toBe('just plain text');
});
it('should stringify last object when responsePath finds nothing in any line', () => {
const lines = [
JSON.stringify({ type: 'progress', data: 1 }),
JSON.stringify({ type: 'done', data: 2 }),
].join('\n');
const result = parseJsonlOutput(lines, '$.nonexistent.path');
// Falls back to stringifying the last parsed object
expect(result.content).toBe(JSON.stringify({ type: 'done', data: 2 }));
});
it('should extract content from "text" field', () => {
const line = JSON.stringify({ text: 'from text field' });
const result = parseJsonlOutput(line);
expect(result.content).toBe('from text field');
});
it('should handle JSONL line that is a bare JSON string', () => {
const lines = '"just a string"';
const result = parseJsonlOutput(lines);
expect(result.content).toBe('just a string');
});
it('should stringify last object when no common fields are present', () => {
const lines = [
JSON.stringify({ foo: 'bar', baz: 123 }),
].join('\n');
const result = parseJsonlOutput(lines);
expect(result.content).toBe(JSON.stringify({ foo: 'bar', baz: 123 }));
});
});