From cb8b8f5b9600bf9f9438d3080541f21d2c3d25a8 Mon Sep 17 00:00:00 2001 From: ISTHA1607 Date: Thu, 23 Jul 2026 23:46:21 +0100 Subject: [PATCH] feat(output): add csv and ndjson output modes for list commands Extends --output to accept csv/ndjson in addition to json/text, for project list, test list, and test result --history. Enables piping CLI output directly into spreadsheets/log pipelines without custom parsing. --- package-lock.json | 4 +- src/commands/doctor.test.ts | 2 +- src/commands/project.test.ts | 168 ++++++++++++++++++ src/commands/project.ts | 43 ++++- src/commands/test.ts | 98 +++++++++- src/index.ts | 7 +- src/lib/output.ts | 109 +++++++++++- test/__snapshots__/help.snapshot.test.ts.snap | 5 +- 8 files changed, 423 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44610169..f9afe033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "commander": "^12.1.0", diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 7974947b..13373c84 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -289,7 +289,7 @@ describe('createDoctorCommand wiring', () => { expect(rejection).toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5, - nextAction: 'Flag `--output` is invalid: must be one of: json, text.', + nextAction: 'Flag `--output` is invalid: must be one of: json, text, csv, ndjson.', }); }); diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index ae98070b..fd9a4f51 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -1423,3 +1423,171 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); }); }); + +// --------------------------------------------------------------------------- +// issue #164 — `project list --output csv|ndjson` +// --------------------------------------------------------------------------- + +describe('project list --output csv', () => { + it('renders an RFC 4180 header + one row per project, quoting fields with commas/quotes/newlines', async () => { + const { credentialsPath } = makeCreds(); + const quirky: CliProject = { + ...PROJECT_FIXTURE, + id: 'project_quirky', + name: 'Say "hi", then\nnewline', + }; + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE, quirky], nextToken: null }, + })); + + const out: string[] = []; + const err: string[] = []; + await runList( + { profile: 'default', output: 'csv', debug: false }, + { credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) }, + ); + + expect(out).toHaveLength(1); + const lines = out[0]!.split('\r\n'); + expect(lines[0]).toBe('id,name,type,createdFrom,createdAt,updatedAt'); + expect(lines[1]).toBe( + `${PROJECT_FIXTURE.id},${PROJECT_FIXTURE.name},${PROJECT_FIXTURE.type},${PROJECT_FIXTURE.createdFrom},${PROJECT_FIXTURE.createdAt},${PROJECT_FIXTURE.updatedAt}`, + ); + // Embedded comma, double quote, and (raw) newline all force RFC 4180 + // quoting; the embedded `"` is doubled per §2 rule 7. The record is one + // CSV row even though it contains a literal `\n` — only `\r\n` is a row + // separator, so `.split('\r\n')` does not break it apart. + expect(lines).toHaveLength(3); + expect(lines[2]).toBe( + 'project_quirky,"Say ""hi"", then\nnewline",frontend,portal,2026-04-15T10:23:00.000Z,2026-05-05T08:12:00.000Z', + ); + expect(err).toEqual([]); + }); + + it('emits the header row only (no data rows) for an empty page', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } })); + + const out: string[] = []; + await runList( + { profile: 'default', output: 'csv', debug: false }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + expect(out).toEqual(['id,name,type,createdFrom,createdAt,updatedAt']); + }); + + it('routes the pagination cursor to stderr, keeping stdout pure CSV', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' }, + })); + + const out: string[] = []; + const err: string[] = []; + await runList( + { profile: 'default', output: 'csv', debug: false, pageSize: 1 }, + { credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) }, + ); + + expect(out).toHaveLength(1); + expect(out[0]).not.toContain('nextToken'); + expect(err).toEqual(['nextToken: opaque-cursor-1']); + }); +}); + +describe('project list --output ndjson', () => { + it('emits one compact JSON object per line, no wrapping array', async () => { + const { credentialsPath } = makeCreds(); + const second = { ...PROJECT_FIXTURE, id: 'project_2' }; + const fetchImpl = makeFetch(() => ({ body: { items: [PROJECT_FIXTURE, second], nextToken: null } })); + + const out: string[] = []; + await runList( + { profile: 'default', output: 'ndjson', debug: false }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + expect(out).toHaveLength(1); + const lines = out[0]!.split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]!)).toEqual(PROJECT_FIXTURE); + expect(JSON.parse(lines[1]!)).toEqual(second); + // Compact — no pretty-printed indentation. + expect(lines[0]).not.toContain('\n '); + }); + + it('writes nothing to stdout for an empty page (no stray blank line)', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } })); + + const out: string[] = []; + await runList( + { profile: 'default', output: 'ndjson', debug: false }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + expect(out).toEqual([]); + }); + + it('routes the pagination cursor to stderr as JSON, keeping stdout pure NDJSON', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' }, + })); + + const out: string[] = []; + const err: string[] = []; + await runList( + { profile: 'default', output: 'ndjson', debug: false, pageSize: 1 }, + { credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) }, + ); + + expect(out).toHaveLength(1); + expect(err).toEqual([JSON.stringify({ nextToken: 'opaque-cursor-1' })]); + }); +}); + +describe('issue #164 — single-object commands reject --output csv|ndjson', () => { + it('runGet exits 5 (VALIDATION_ERROR) for --output csv', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE })); + + await expect( + runGet( + { profile: 'default', output: 'csv', debug: false, projectId: 'p1' }, + { credentialsPath, fetchImpl, stdout: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runGet exits 5 (VALIDATION_ERROR) for --output ndjson', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE })); + + await expect( + runGet( + { profile: 'default', output: 'ndjson', debug: false, projectId: 'p1' }, + { credentialsPath, fetchImpl, stdout: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCreate exits 5 (VALIDATION_ERROR) for --output csv', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE })); + + await expect( + runCreate( + { + profile: 'default', + output: 'csv', + debug: false, + type: 'backend', + name: 'Checkout', + }, + { credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); +}); diff --git a/src/commands/project.ts b/src/commands/project.ts index 6a0b3321..31fec168 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -10,7 +10,13 @@ import { import { ApiError } from '../lib/errors.js'; import type { FetchImpl } from '../lib/http.js'; import type { HttpClient } from '../lib/http.js'; -import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js'; +import { + GLOBAL_OPTS_HINT, + Output, + resolveOutputMode, + type ListColumn, + type OutputMode, +} from '../lib/output.js'; import { assertNotLocal } from '../lib/target-url.js'; import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js'; import { assertIdempotencyKey } from '../lib/validate.js'; @@ -88,6 +94,27 @@ export async function runList( ); } + // csv/ndjson: routed here (before `out.print`, which rejects both modes + // for every non-list command) so the row data goes to stdout and the + // pagination cursor goes to stderr — keeping stdout a pure, parseable + // table/stream per M-hackathon-164. + if (opts.output === 'csv' || opts.output === 'ndjson') { + const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); + if (opts.output === 'csv') { + out.printCsv(page.items, PROJECT_CSV_COLUMNS); + } else { + out.printNdjson(page.items); + } + if (page.nextToken) { + stderr( + opts.output === 'csv' + ? `nextToken: ${page.nextToken}` + : JSON.stringify({ nextToken: page.nextToken }), + ); + } + return page; + } + out.print(page, data => { const p = data as Page; return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader }); @@ -1029,6 +1056,20 @@ const PROJECT_LIST_COLUMNS: ReadonlyArray> = [ { header: 'CREATED', width: 0, render: project => project.createdAt }, ]; +/** + * `--output csv`/`--output ndjson` columns for `project list`, derived + * straight from the `CliProject` source-of-truth shape (not the + * possibly-reordered/subset `PROJECT_LIST_COLUMNS` used for `--output text`). + */ +const PROJECT_CSV_COLUMNS: ReadonlyArray> = [ + { header: 'id', value: p => p.id }, + { header: 'name', value: p => p.name }, + { header: 'type', value: p => p.type }, + { header: 'createdFrom', value: p => p.createdFrom }, + { header: 'createdAt', value: p => p.createdAt }, + { header: 'updatedAt', value: p => p.updatedAt }, +]; + function renderProjectListText( page: Page, options: { columns?: string; noHeader?: boolean } = {}, diff --git a/src/commands/test.ts b/src/commands/test.ts index ca6801c1..64491733 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -54,7 +54,13 @@ import { import { REQUEST_TIMEOUT_DEFAULT_MS, REQUEST_TIMEOUT_MAX_MS } from '../lib/http.js'; import type { FetchImpl } from '../lib/http.js'; import type { HttpClient } from '../lib/http.js'; -import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js'; +import { + GLOBAL_OPTS_HINT, + Output, + resolveOutputMode, + type ListColumn, + type OutputMode, +} from '../lib/output.js'; import { fetchSinglePage, paginate, @@ -604,6 +610,26 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise

process.stderr.write(`${line}\n`)); + if (opts.output === 'csv') { + out.printCsv(page.items, TEST_CSV_COLUMNS); + } else { + out.printNdjson(page.items); + } + if (page.nextToken) { + stderr( + opts.output === 'csv' + ? `nextToken: ${page.nextToken}` + : JSON.stringify({ nextToken: page.nextToken }), + ); + } + return page; + } + out.print(page, data => renderTestListText(data as Page, { columns: opts.columns, noHeader: opts.noHeader }), ); @@ -4620,11 +4646,34 @@ export async function runResultHistory( since: sinceIso, }); - if (opts.output !== 'text') { + if (opts.output === 'json') { out.print({ runs: resp.runs, nextCursor: resp.nextCursor }, data => JSON.stringify(data)); return resp; } + // csv/ndjson: handled before `out.print` (which rejects both modes for + // every non-list command) — run rows go to stdout, `nextCursor` and the + // `meta` block go to stderr so stdout stays a pure, parseable table/stream + // (M-hackathon-164). + if (opts.output === 'csv' || opts.output === 'ndjson') { + const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); + if (opts.output === 'csv') { + out.printCsv(resp.runs, RUN_HISTORY_CSV_COLUMNS); + } else { + out.printNdjson(resp.runs); + } + if (resp.nextCursor !== null) { + stderr( + opts.output === 'csv' + ? `nextCursor: ${resp.nextCursor}` + : JSON.stringify({ nextCursor: resp.nextCursor }), + ); + } + if (resp.meta.note) stderr(`note: ${resp.meta.note}`); + if (resp.meta.portalUrl) stderr(`portal: ${resp.meta.portalUrl}`); + return resp; + } + // Text mode rendering const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); @@ -4697,6 +4746,27 @@ const RUN_HISTORY_TABLE_COLUMNS: ReadonlyArray> }, ]; +/** + * `--output csv`/`--output ndjson` columns for `test result --history`, + * derived straight from the `RunHistoryItem` source-of-truth shape (not the + * text-table-only `RUN_HISTORY_TABLE_COLUMNS`, which drops several fields + * and derives `RERUN?`/`DURATION` for display). + */ +const RUN_HISTORY_CSV_COLUMNS: ReadonlyArray> = [ + { header: 'runId', value: run => run.runId }, + { header: 'status', value: run => run.status }, + { header: 'source', value: run => run.source }, + { header: 'isRerun', value: run => run.isRerun }, + { header: 'createdFrom', value: run => run.createdFrom }, + { header: 'createdAt', value: run => run.createdAt }, + { header: 'startedAt', value: run => run.startedAt }, + { header: 'finishedAt', value: run => run.finishedAt }, + { header: 'codeVersion', value: run => run.codeVersion }, + { header: 'failureKind', value: run => run.failureKind }, + { header: 'targetUrl', value: run => run.targetUrl }, + { header: 'targetUrlSource', value: run => run.targetUrlSource }, +]; + /** * Max width of the `test steps` DESCRIPTION column in text mode. Long / * multi-line step descriptions are collapsed to one line and truncated to @@ -10170,6 +10240,30 @@ const TEST_LIST_COLUMNS: ReadonlyArray> = [ { header: 'UPDATED', width: 0, render: test => test.updatedAt }, ]; +/** + * `--output csv`/`--output ndjson` columns for `test list`, derived straight + * from the `CliTest` source-of-truth shape (not the possibly-reordered/subset + * `TEST_LIST_COLUMNS` used for `--output text`). `produces`/`consumes` are + * comma-joined; `renderCsv`'s escaping quotes the cell automatically since a + * joined list contains commas. + */ +const TEST_CSV_COLUMNS: ReadonlyArray> = [ + { header: 'id', value: t => t.id }, + { header: 'projectId', value: t => t.projectId }, + { header: 'projectName', value: t => t.projectName }, + { header: 'name', value: t => t.name }, + { header: 'type', value: t => t.type }, + { header: 'createdFrom', value: t => t.createdFrom }, + { header: 'status', value: t => t.status }, + { header: 'createdAt', value: t => t.createdAt }, + { header: 'updatedAt', value: t => t.updatedAt }, + { header: 'planStepCount', value: t => t.planStepCount }, + { header: 'priority', value: t => t.priority }, + { header: 'produces', value: t => t.produces?.join(',') }, + { header: 'consumes', value: t => t.consumes?.join(',') }, + { header: 'category', value: t => t.category }, +]; + function renderTestListText( page: Page, options: { columns?: string; noHeader?: boolean } = {}, diff --git a/src/index.ts b/src/index.ts index 31d3399b..abc76879 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,12 @@ program .name('testsprite') .description('Official TestSprite command-line interface') .version(VERSION) - .option('--output ', 'Output format (json|text)', 'text') + .option( + '--output ', + 'Output format (json|text|csv|ndjson). csv/ndjson are only supported by list commands ' + + '(project list, test list, test result --history).', + 'text', + ) .option('--profile ', 'Configuration profile to use') .option('--endpoint-url ', 'Override the API endpoint host') .option( diff --git a/src/lib/output.ts b/src/lib/output.ts index 4d59ccae..c8d1a81c 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,6 +1,14 @@ import { localValidationError } from './errors.js'; -export type OutputMode = 'json' | 'text'; +export type OutputMode = 'json' | 'text' | 'csv' | 'ndjson'; + +/** + * Output modes accepted by the `--output` flag. `csv` and `ndjson` are only + * meaningful for list-style commands (`project list`, `test list`, + * `test result --history`) — every other command rejects them via + * {@link Output.print} (see that method's doc comment). + */ +const OUTPUT_MODES: readonly OutputMode[] = ['json', 'text', 'csv', 'ndjson']; /** * Help-text footer pointing at the global options surface so users @@ -12,15 +20,15 @@ export const GLOBAL_OPTS_HINT = '\n testsprite --help'; export function isOutputMode(value: unknown): value is OutputMode { - return value === 'json' || value === 'text'; + return (OUTPUT_MODES as readonly unknown[]).includes(value); } /** * Resolve a raw `--output` flag value to a concrete {@link OutputMode}. * * `undefined` (flag omitted) resolves to the default `'text'`. Any other - * value that is not `'json'` or `'text'` throws a typed VALIDATION_ERROR - * (exit 5) with an actionable message. + * value that is not one of `'json' | 'text' | 'csv' | 'ndjson'` throws a + * typed VALIDATION_ERROR (exit 5) with an actionable message. * * The alternative — silently falling back to `'text'` — is a footgun for the * CLI's primary consumer (coding agents): a caller that asks for @@ -28,11 +36,64 @@ export function isOutputMode(value: unknown): value is OutputMode { * human-readable text payload and fail to parse it as JSON, with no signal as * to why. Every command group routes its global-option resolution through this * helper so the validation is uniform. + * + * `csv` and `ndjson` are accepted here (so `--output csv` on any command + * parses) but are only actually rendered by list commands; every other + * command rejects them at print time via {@link Output.print}. */ export function resolveOutputMode(raw: unknown): OutputMode { if (raw === undefined) return 'text'; if (isOutputMode(raw)) return raw; - throw localValidationError('output', 'must be one of: json, text', ['json', 'text']); + throw localValidationError('output', 'must be one of: json, text, csv, ndjson', OUTPUT_MODES); +} + +/** + * One column of a CSV/NDJSON list rendering, derived straight from a + * source-of-truth wire type (e.g. `CliProject`, `CliTest`, + * `RunHistoryItem`) rather than the (possibly reordered/truncated) text + * table columns used for `--output text`. + */ +export interface ListColumn { + /** CSV header cell / JSON-ish field name for this column. */ + header: string; + /** Extract the raw value for this column from one row. */ + value: (row: T) => unknown; +} + +/** + * Escape one CSV field per RFC 4180 §2: + * - `null`/`undefined` render as the empty string. + * - Fields containing a comma, double quote, or CR/LF are wrapped in + * double quotes, with embedded double quotes doubled (`"` → `""`). + */ +export function csvEscapeField(raw: unknown): string { + if (raw === null || raw === undefined) return ''; + const s = String(raw); + if (/[",\r\n]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** + * Render rows as an RFC 4180 CSV table: a header row followed by one row + * per item, fields joined with `,` and records joined with the RFC-mandated + * `\r\n` line terminator (no trailing terminator on the final record). + */ +export function renderCsv(rows: readonly T[], columns: readonly ListColumn[]): string { + const header = columns.map(column => csvEscapeField(column.header)).join(','); + const body = rows.map(row => columns.map(column => csvEscapeField(column.value(row))).join(',')); + return [header, ...body].join('\r\n'); +} + +/** + * Render rows as newline-delimited JSON: one compact `JSON.stringify`'d + * object per line, no wrapping array. Returns `''` (no lines) for an empty + * `rows` array — callers should skip the stdout write entirely in that case + * so no stray blank line is emitted. + */ +export function renderNdjson(rows: readonly T[]): string { + return rows.map(row => JSON.stringify(row)).join('\n'); } export interface OutputStreams { @@ -87,7 +148,25 @@ export class Output { this.rawStdoutWrite = streams.rawStdout ?? defaultRawStdout; } + /** + * Print a single JSON/text envelope. `csv`/`ndjson` are rejected here with + * a VALIDATION_ERROR (exit 5): those two modes only make sense for a table + * of rows, and every non-list command (get/create/update/delete/...) + * renders its single-object result through this method — centralizing the + * rejection here means every such command rejects `--output csv|ndjson` + * without each of them needing its own guard. List commands (`project + * list`, `test list`, `test result --history`) branch on `csv`/`ndjson` + * *before* reaching this method and call {@link Output.printCsv} / + * {@link Output.printNdjson} instead. + */ print(data: unknown, textRenderer?: (data: unknown) => string): void { + if (this.mode === 'csv' || this.mode === 'ndjson') { + throw localValidationError( + 'output', + `'${this.mode}' is only supported by list commands (project list, test list, ` + + 'test result --history); use --output json or --output text here', + ); + } if (this.mode === 'json' || !textRenderer) { this.stdoutWrite(JSON.stringify(data, null, 2)); return; @@ -95,6 +174,26 @@ export class Output { this.stdoutWrite(textRenderer(data)); } + /** + * Render `rows` as an RFC 4180 CSV table (header + one row per item) and + * write it to stdout as a single chunk. No-op guard against being called + * with a non-`csv` mode is intentionally omitted — callers already branch + * on `opts.output === 'csv'` before reaching here. + */ + printCsv(rows: readonly T[], columns: readonly ListColumn[]): void { + this.stdoutWrite(renderCsv(rows, columns)); + } + + /** + * Render `rows` as newline-delimited JSON and write it to stdout as a + * single chunk. Writes nothing when `rows` is empty, so an empty list + * never emits a stray blank line on stdout. + */ + printNdjson(rows: readonly T[]): void { + const body = renderNdjson(rows); + if (body.length > 0) this.stdoutWrite(body); + } + /** * Write a chunk of bytes to stdout verbatim. Awaits any * Promise the rawStdout writer returns so a slow downstream diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 55a7252f..5ab17927 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -730,7 +730,10 @@ Official TestSprite command-line interface Options: -V, --version output the version number - --output Output format (json|text) (default: "text") + --output Output format (json|text|csv|ndjson). csv/ndjson + are only supported by list commands (project + list, test list, test result --history). + (default: "text") --profile Configuration profile to use --endpoint-url Override the API endpoint host --verbose Emit human-readable HTTP retry / backoff /