Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi

const verbose = optionsRecord.verbose === true;
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
const formatExplicit = subCmd.getOptionValueSource('format') === 'cli';
if (verbose) process.env.OPENCLI_VERBOSE = '1';
if (cmd.deprecated) {
const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
Expand All @@ -109,7 +110,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
}

const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
if (format === 'table' && resolved.defaultFormat) {
if (!formatExplicit && format === 'table' && resolved.defaultFormat) {
format = resolved.defaultFormat;
}

Expand All @@ -118,6 +119,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
}
renderOutput(result, {
fmt: format,
fmtExplicit: formatExplicit,
columns: resolved.columns,
title: `${resolved.site}/${resolved.name}`,
elapsed: (Date.now() - startTime) / 1000,
Expand Down
140 changes: 50 additions & 90 deletions src/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,109 +1,69 @@
/**
* Tests for output.ts: render function format coverage.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render } from './output.js';

afterEach(() => {
vi.restoreAllMocks();
});

describe('render', () => {
it('renders JSON output', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
expect(log).toHaveBeenCalledOnce();
const output = log.mock.calls[0]?.[0];
const parsed = JSON.parse(output);
expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
});

it('renders Markdown table output', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
const calls = log.mock.calls.map(c => c[0]);
expect(calls[0]).toContain('| name | score |');
expect(calls[1]).toContain('| --- | --- |');
expect(calls[2]).toContain('| Alice | 100 |');
});

it('renders CSV output with proper quoting', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
const calls = log.mock.calls.map(c => c[0]);
// Header
expect(calls[0]).toBe('name,value');
// Values with commas/quotes are quoted
expect(calls[1]).toContain('"Alice, Bob"');
expect(calls[1]).toContain('"say ""hi"""');
});

it('handles null and undefined data', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render(null, { fmt: 'json' });
expect(log).toHaveBeenCalledWith(null);
});
describe('output TTY detection', () => {
const originalIsTTY = process.stdout.isTTY;
const originalEnv = process.env.OUTPUT;
let logSpy: ReturnType<typeof vi.spyOn>;

it('renders single object as single-row table', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render({ title: 'Test' }, { fmt: 'json' });
const output = log.mock.calls[0]?.[0];
const parsed = JSON.parse(output);
expect(parsed).toEqual({ title: 'Test' });
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

it('handles empty array gracefully', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([], { fmt: 'table' });
// Should show "(no data)" for empty arrays
expect(log).toHaveBeenCalled();
afterEach(() => {
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true });
if (originalEnv === undefined) delete process.env.OUTPUT;
else process.env.OUTPUT = originalEnv;
logSpy.mockRestore();
});

it('uses custom columns for CSV', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
const calls = log.mock.calls.map(c => c[0]);
expect(calls[0]).toBe('a,c');
expect(calls[1]).toBe('1,3');
it('outputs YAML in non-TTY when format is default table', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
// commanderAdapter always passes fmt:'table' as default — this must still trigger downgrade
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(out).toContain('name: alice');
expect(out).toContain('score: 10');
});

it('renders YAML output', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
expect(log).toHaveBeenCalledOnce();
expect(log.mock.calls[0]?.[0]).toContain('- title: Hello');
expect(log.mock.calls[0]?.[0]).toContain('rank: 1');
it('outputs table in TTY when format is default table', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(out).toContain('alice');
});

it('renders yml alias as YAML output', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render({ title: 'Hello' }, { fmt: 'yml' });
expect(log).toHaveBeenCalledOnce();
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
it('respects explicit -f json even in non-TTY', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
render([{ name: 'alice' }], { fmt: 'json' });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
});

it('handles null values in CSV cells', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ name: 'test', value: null }], { fmt: 'csv' });
const calls = log.mock.calls.map(c => c[0]);
expect(calls[1]).toBe('test,');
it('OUTPUT env var overrides default table in non-TTY', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
process.env.OUTPUT = 'json';
render([{ name: 'alice' }], { fmt: 'table' });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
});

it('renders single-field rows in plain mode as the bare value', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ response: 'Gemini says hi' }], { fmt: 'plain' });
expect(log).toHaveBeenCalledWith('Gemini says hi');
it('explicit -f flag takes precedence over OUTPUT env var', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
process.env.OUTPUT = 'json';
render([{ name: 'alice' }], { fmt: 'csv', fmtExplicit: true });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(out).toContain('name');
expect(out).toContain('alice');
expect(out).not.toContain('"name"'); // not JSON
});

it('renders multi-field rows in plain mode as key-value lines', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
render([{ status: 'ok', file: '~/tmp/a.png', link: 'https://example.com' }], { fmt: 'plain' });
const calls = log.mock.calls.map(c => c[0]);
expect(calls).toEqual([
'status: ok',
'file: ~/tmp/a.png',
'link: https://example.com',
]);
it('explicit -f table overrides non-TTY auto-downgrade', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
// Should be table output, not YAML
expect(out).not.toContain('name: alice');
expect(out).toContain('alice');
});
});
11 changes: 10 additions & 1 deletion src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import yaml from 'js-yaml';

export interface RenderOptions {
fmt?: string;
/** True when the user explicitly passed -f on the command line */
fmtExplicit?: boolean;
columns?: string[];
title?: string;
elapsed?: number;
Expand All @@ -26,7 +28,14 @@ function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): s
}

export function render(data: unknown, opts: RenderOptions = {}): void {
const fmt = opts.fmt ?? 'table';
let fmt = opts.fmt ?? 'table';
// Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
// Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table
if (!opts.fmtExplicit) {
const envFmt = process.env.OUTPUT?.trim().toLowerCase();
if (envFmt) fmt = envFmt;
else if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml';
}
if (data === null || data === undefined) {
console.log(data);
return;
Expand Down