From 4189f1f8b032b97b32e116cf599d3067c40d692d Mon Sep 17 00:00:00 2001 From: nopp Date: Thu, 16 Jul 2026 14:19:08 +0700 Subject: [PATCH 1/2] feat(cli): add text list column controls --- src/commands/project.test.ts | 73 +++++++ src/commands/project.ts | 76 ++++---- src/commands/test.result.history.spec.ts | 96 ++++++++++ src/commands/test.test.ts | 76 ++++++++ src/commands/test.ts | 180 +++++++++--------- src/lib/text-table.ts | 94 +++++++++ test/__snapshots__/help.snapshot.test.ts.snap | 8 + 7 files changed, 474 insertions(+), 129 deletions(-) create mode 100644 src/lib/text-table.ts diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index daea851..6462a6d 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -87,6 +87,8 @@ describe('createProjectCommand', () => { expect(flagNames).toContain('--page-size'); expect(flagNames).toContain('--starting-token'); expect(flagNames).toContain('--max-items'); + expect(flagNames).toContain('--columns'); + expect(flagNames).toContain('--no-header'); }); }); @@ -283,6 +285,77 @@ describe('runList', () => { expect(block).toContain('nextToken: next-please'); }); + it('text output selects/reorders columns and suppresses the header', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE], nextToken: null }, + })); + + const out: string[] = []; + await runList( + { + profile: 'default', + output: 'text', + debug: false, + pageSize: 25, + columns: 'name,id', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + const block = out.join('\n'); + expect(block).toMatch(/^Checkout\s+project_b3c91efa$/); + expect(block).not.toContain('NAME'); + expect(block).not.toContain('CREATED'); + }); + + it('text output rejects unknown columns with VALIDATION_ERROR', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE], nextToken: null }, + })); + + await expect( + runList( + { + profile: 'default', + output: 'text', + debug: false, + pageSize: 25, + columns: 'bogus', + }, + { credentialsPath, fetchImpl, stdout: () => undefined }, + ), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + details: { field: 'columns' }, + }); + }); + + it('json output ignores text-only column flags', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [PROJECT_FIXTURE], nextToken: null }, + })); + + const out: string[] = []; + await runList( + { + profile: 'default', + output: 'json', + debug: false, + pageSize: 25, + columns: 'bogus', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + expect(JSON.parse(out.join('\n')).items[0].id).toBe('project_b3c91efa'); + }); + it('text output reads "No projects." when items is empty and nextToken is null', async () => { const { credentialsPath } = makeCreds(); const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } })); diff --git a/src/commands/project.ts b/src/commands/project.ts index 96f2fc8..8f92b21 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -12,6 +12,7 @@ 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 { assertNotLocal } from '../lib/target-url.js'; +import { renderTextTable, type TextTableColumn } from '../lib/text-table.js'; import { assertIdempotencyKey } from '../lib/validate.js'; import { fetchSinglePage, @@ -44,6 +45,8 @@ interface ListOptions extends CommonOptions { pageSize?: number; startingToken?: string; maxItems?: number; + columns?: string; + noHeader?: boolean; } export async function runList( @@ -84,7 +87,7 @@ export async function runList( out.print(page, data => { const p = data as Page; - return renderProjectListText(p); + return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader }); }); return page; } @@ -584,6 +587,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { .option('--page-size ', 'service page-size hint (1-100, default 25)') .option('--starting-token ', 'opaque cursor from a previous list response') .option('--max-items ', 'stop after this many items across auto-paged pages') + .option('--columns ', 'select/reorder text table columns (comma-separated keys)') + .option('--no-header', 'suppress the text table header row') .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (cmdOpts: ListFlagOpts, command: Command) => { // Don't parse numeric flags via Commander — its parser throws a @@ -597,6 +602,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command { pageSize: parseFlag(cmdOpts.pageSize, 'page-size'), startingToken: cmdOpts.startingToken, maxItems: parseFlag(cmdOpts.maxItems, 'max-items'), + columns: cmdOpts.columns, + noHeader: cmdOpts.header === false, }, deps, ); @@ -784,6 +791,8 @@ interface ListFlagOpts { pageSize?: string; startingToken?: string; maxItems?: string; + columns?: string; + header?: boolean; } interface CreateFlagOpts { @@ -885,45 +894,37 @@ function makeOutput(mode: OutputMode, deps: ProjectDeps): Output { return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr }); } -function renderProjectListText(page: Page): string { +const PROJECT_LIST_COLUMNS: ReadonlyArray> = [ + { + header: 'ID', + width: rows => Math.max(2, ...rows.map(project => project.id.length)), + render: project => project.id, + }, + { + header: 'NAME', + width: rows => Math.max(4, ...rows.map(project => project.name.length)), + render: project => project.name, + }, + { header: 'TYPE', width: 8, render: project => project.type }, + { header: 'FROM', width: 6, render: project => project.createdFrom }, + { header: 'CREATED', width: 0, render: project => project.createdAt }, +]; + +function renderProjectListText( + page: Page, + options: { columns?: string; noHeader?: boolean } = {}, +): string { if (page.items.length === 0) { return page.nextToken ? `No projects on this page.\nnextToken: ${page.nextToken}` : 'No projects.'; } - // Compact, AWS-CLI-grade columnar output. Column widths are computed - // per-call so a single absurdly long project name doesn't push the - // whole table off-screen. - const idWidth = Math.max(2, ...page.items.map(p => p.id.length)); - const nameWidth = Math.max(4, ...page.items.map(p => p.name.length)); - const typeWidth = 8; - const fromWidth = 6; - - const header = - pad('ID', idWidth) + - ' ' + - pad('NAME', nameWidth) + - ' ' + - pad('TYPE', typeWidth) + - ' ' + - pad('FROM', fromWidth) + - ' ' + - 'CREATED'; - - const rows = page.items.map( - p => - pad(p.id, idWidth) + - ' ' + - pad(p.name, nameWidth) + - ' ' + - pad(p.type, typeWidth) + - ' ' + - pad(p.createdFrom, fromWidth) + - ' ' + - p.createdAt, - ); - - const lines = [header, ...rows]; + const lines = [ + renderTextTable(page.items, PROJECT_LIST_COLUMNS, { + columns: options.columns, + noHeader: options.noHeader, + }), + ]; if (page.nextToken) lines.push('', `nextToken: ${page.nextToken}`); return lines.join('\n'); } @@ -939,11 +940,6 @@ function renderProjectText(p: CliProject): string { ].join('\n'); } -function pad(s: string, width: number): string { - if (s.length >= width) return s; - return s + ' '.repeat(width - s.length); -} - function renderUpdateText(r: CliUpdateProjectResponse): string { return [ `id: ${r.id}`, diff --git a/src/commands/test.result.history.spec.ts b/src/commands/test.result.history.spec.ts index a2d2018..ed0c43d 100644 --- a/src/commands/test.result.history.spec.ts +++ b/src/commands/test.result.history.spec.ts @@ -225,6 +225,102 @@ describe('runResultHistory — text mode', () => { expect(output).toMatch(/DURATION/); }); + it('selects/reorders columns and suppresses header/separator/detail sub-lines', async () => { + const { credentialsPath } = makeCreds(); + const lines: string[] = []; + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/test_abc/runs')) { + return { + body: makeHistoryResp([ + makeHistoryItem({ + runId: 'run_url_001', + targetUrl: 'https://staging.example.com/checkout', + targetUrlSource: 'run', + }), + ]), + }; + } + return { status: 404, body: errorEnvelope('NOT_FOUND') }; + }); + + await runResultHistory( + { + output: 'text', + testId: 'test_abc', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + columns: 'status,runid', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => lines.push(line) }, + ); + + const output = lines.join('\n'); + expect(output.split('\n')[0]).toMatch(/^passed\s+run_url_001$/); + expect(output).not.toMatch(/^RUN ID/m); + expect(output).not.toMatch(/^-+$/m); + expect(output).not.toContain('targetUrl:'); + }); + + it('rejects unknown history columns with VALIDATION_ERROR', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/test_abc/runs')) { + return { body: makeHistoryResp() }; + } + return { status: 404, body: errorEnvelope('NOT_FOUND') }; + }); + + await expect( + runResultHistory( + { + output: 'text', + testId: 'test_abc', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + columns: 'bogus', + }, + { credentialsPath, fetchImpl, stdout: () => undefined }, + ), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + details: { field: 'columns' }, + }); + }); + + it('json mode ignores text-only history column flags', async () => { + const { credentialsPath } = makeCreds(); + const lines: string[] = []; + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/test_abc/runs')) { + return { body: makeHistoryResp() }; + } + return { status: 404, body: errorEnvelope('NOT_FOUND') }; + }); + + await runResultHistory( + { + output: 'json', + testId: 'test_abc', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + columns: 'bogus', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => lines.push(line) }, + ); + + const parsed = JSON.parse(lines.join('')) as { runs: Array<{ runId: string }> }; + expect(parsed.runs[0]?.runId).toBe('run_hist_001'); + }); + it('renders run_hist_001 row with status passed and source cli', async () => { const { credentialsPath } = makeCreds(); const lines: string[] = []; diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index f7c7602..1a3e6a3 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -199,6 +199,7 @@ describe('createTestCommand — surface', () => { it('result exposes --include-analysis (M2.1) + M3.4 piece-5 --history flags', () => { // M2.1 piece 3 adds `--include-analysis` to `test result`. // M3.4 piece 5 adds `--history`, `--source`, `--since`, `--page-size`, `--cursor`. + // Issue #165 adds text-table shaping via `--columns` and `--no-header`. // Pinning the surface so a future flag-consolidation sweep keeps every // option intentional. Back-compat: bare `test result ` (no --history) // still calls runResult and returns the M2 CliLatestResult shape. @@ -212,6 +213,8 @@ describe('createTestCommand — surface', () => { '--since', '--page-size', '--cursor', + '--columns', + '--no-header', ]); }); @@ -574,6 +577,79 @@ describe('runList', () => { expect(block).toContain('mcp'); }); + it('text mode selects/reorders columns and suppresses the header', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [FE_TEST, BE_TEST], nextToken: null }, + })); + const out: string[] = []; + await runList( + { + profile: 'default', + output: 'text', + debug: false, + projectId: 'project_alice', + pageSize: 25, + columns: 'status,id', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + const lines = out.join('\n').split('\n'); + expect(lines[0]).toMatch(/^failed\s+test_fe$/); + expect(lines[1]).toMatch(/^passed\s+test_be$/); + expect(out.join('\n')).not.toContain('STATUS'); + expect(out.join('\n')).not.toContain('UPDATED'); + }); + + it('text mode rejects unknown columns with VALIDATION_ERROR', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [FE_TEST], nextToken: null }, + })); + + await expect( + runList( + { + profile: 'default', + output: 'text', + debug: false, + projectId: 'project_alice', + pageSize: 25, + columns: 'bogus', + }, + { credentialsPath, fetchImpl, stdout: () => undefined }, + ), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + details: { field: 'columns' }, + }); + }); + + it('json mode ignores text-only column flags', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = makeFetch(() => ({ + body: { items: [FE_TEST], nextToken: null }, + })); + const out: string[] = []; + await runList( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'project_alice', + pageSize: 25, + columns: 'bogus', + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => out.push(line) }, + ); + + expect(JSON.parse(out.join('\n')).items[0].id).toBe('test_fe'); + }); + it('text mode reads "No tests." when items is empty and nextToken is null', async () => { const { credentialsPath } = makeCreds(); const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } })); diff --git a/src/commands/test.ts b/src/commands/test.ts index 0602afc..c296064 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -78,6 +78,13 @@ import type { } from '../lib/runs.types.js'; import { RUN_SOURCES } from '../lib/runs.types.js'; import { assertNotLocal } from '../lib/target-url.js'; +import { + formatTextTableRow, + measureTextColumns, + renderTextTable, + resolveTextColumns, + type TextTableColumn, +} from '../lib/text-table.js'; import { createTicker } from '../lib/ticker.js'; import { RateThrottle } from '../lib/rate-throttle.js'; import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js'; @@ -431,6 +438,8 @@ interface ListOptions extends CommonOptions { pageSize?: number; startingToken?: string; maxItems?: number; + columns?: string; + noHeader?: boolean; } const TEST_TYPES: ReadonlyArray<'frontend' | 'backend'> = ['frontend', 'backend']; @@ -529,7 +538,9 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise

renderTestListText(data as Page)); + out.print(page, data => + renderTestListText(data as Page, { columns: opts.columns, noHeader: opts.noHeader }), + ); return page; } @@ -4353,6 +4364,8 @@ interface ResultHistoryOptions extends CommonOptions { pageSize?: number; /** Opaque cursor from a prior page's `nextCursor`. */ cursor?: string; + columns?: string; + noHeader?: boolean; } /** @@ -4430,7 +4443,7 @@ export async function runResultHistory( } const lines: string[] = []; - lines.push(renderRunHistoryTable(resp.runs)); + lines.push(renderRunHistoryTable(resp.runs, { columns: opts.columns, noHeader: opts.noHeader })); // Footer: pointer to per-run detail commands. lines.push(''); @@ -4457,13 +4470,18 @@ export async function runResultHistory( return resp; } -const RUN_HISTORY_TABLE_COL_WIDTHS = { - runId: 36, - status: 10, - source: 18, - rerun: 6, - when: 25, -}; +const RUN_HISTORY_TABLE_COLUMNS: ReadonlyArray> = [ + { header: 'RUN ID', width: 36, render: run => run.runId }, + { header: 'STATUS', width: 10, render: run => run.status }, + { header: 'SOURCE', width: 18, render: run => run.source }, + { header: 'RERUN?', width: 6, render: run => (run.isRerun ? 'yes' : 'no') }, + { header: 'WHEN', width: 25, render: run => run.createdAt }, + { + header: 'DURATION', + width: 0, + render: run => formatDurationMs(run.startedAt ?? run.createdAt, run.finishedAt), + }, +]; /** * Max width of the `test steps` DESCRIPTION column in text mode. Long / @@ -4499,60 +4517,41 @@ const HISTORY_TARGET_URL_MAX = 80; * run row (truncated to `HISTORY_TARGET_URL_MAX` chars). The table columns * are left intact to avoid width blow-out on terminals. */ -function renderRunHistoryTable(runs: RunHistoryItem[]): string { - const cols = RUN_HISTORY_TABLE_COL_WIDTHS; - const header = [ - padEnd('RUN ID', cols.runId), - padEnd('STATUS', cols.status), - padEnd('SOURCE', cols.source), - padEnd('RERUN?', cols.rerun), - padEnd('WHEN', cols.when), - 'DURATION', - ].join(' '); - const sep = '-'.repeat(header.length); - - const rows = runs.flatMap(r => { - // FE runs never populate `startedAt` today — the RUNNING heartbeat - // that would set it doesn't fire on the legacy/sync execution path - // (dogfood 2026-06-04), so without a fallback DURATION was always - // "—" for every FE run. Fall back to `createdAt` so the column shows - // wall-clock from trigger to finish; on sync dev the queue gap is - // ~0, and `--output json` still exposes raw startedAt/finishedAt for - // consumers that need to exclude queue time. - const duration = formatDurationMs(r.startedAt ?? r.createdAt, r.finishedAt); - const mainRow = [ - padEnd(r.runId, cols.runId), - padEnd(r.status, cols.status), - padEnd(r.source, cols.source), - padEnd(r.isRerun ? 'yes' : 'no', cols.rerun), - padEnd(r.createdAt, cols.when), - duration, - ].join(' '); +function renderRunHistoryTable( + runs: RunHistoryItem[], + options: { columns?: string; noHeader?: boolean } = {}, +): string { + const selectedColumns = resolveTextColumns(options.columns, RUN_HISTORY_TABLE_COLUMNS); + const widths = measureTextColumns(runs, selectedColumns); + const customColumns = options.columns !== undefined && options.columns.trim() !== ''; + const includeDetailLines = !customColumns && options.noHeader !== true; + const header = formatTextTableRow( + selectedColumns.map(column => column.header), + widths, + ); + + const rows = runs.flatMap(run => { + const mainRow = formatTextTableRow( + selectedColumns.map(column => column.render(run)), + widths, + ); - // G1b: surface per-run targetUrl as an indented sub-line. - // Render only when truthy (skip null, undefined, empty) and when the - // source is not 'unresolved' (that would mean "backend couldn't resolve - // a URL" — printing "—" is less informative than omitting the line). const lines: string[] = [mainRow]; - if (r.targetUrl && r.targetUrlSource !== 'unresolved') { + if (includeDetailLines && run.targetUrl && run.targetUrlSource !== 'unresolved') { const url = - r.targetUrl.length > HISTORY_TARGET_URL_MAX - ? `${r.targetUrl.slice(0, HISTORY_TARGET_URL_MAX - 1)}…` - : r.targetUrl; + run.targetUrl.length > HISTORY_TARGET_URL_MAX + ? `${run.targetUrl.slice(0, HISTORY_TARGET_URL_MAX - 1)}…` + : run.targetUrl; lines.push(` targetUrl: ${url}`); - } else if (r.targetUrlSource === 'unresolved') { + } else if (includeDetailLines && run.targetUrlSource === 'unresolved') { lines.push(` targetUrl: —`); } return lines; }); - return [header, sep, ...rows].join('\n'); -} - -function padEnd(s: string, width: number): string { - if (s.length >= width) return s; - return s + ' '.repeat(width - s.length); + if (options.noHeader === true) return rows.join('\n'); + return [header, '-'.repeat(header.length), ...rows].join('\n'); } function formatDurationMs(startedAt: string | null, finishedAt: string | null): string { @@ -7945,6 +7944,8 @@ export function createTestCommand(deps: TestDeps = {}): Command { 'alias for --starting-token; accepted for parity with `test result --history`', ) .option('--max-items ', 'stop after this many items across auto-paged pages') + .option('--columns ', 'select/reorder text table columns (comma-separated keys)') + .option('--no-header', 'suppress the text table header row') .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (cmdOpts: ListFlagOpts, command: Command) => { // Same parser strategy as `project list`: skip Commander's number @@ -7965,6 +7966,8 @@ export function createTestCommand(deps: TestDeps = {}): Command { pageSize: parseNumericFlag(cmdOpts.pageSize, 'page-size'), startingToken: cmdOpts.startingToken ?? cmdOpts.cursor, maxItems: parseNumericFlag(cmdOpts.maxItems, 'max-items'), + columns: cmdOpts.columns, + noHeader: cmdOpts.header === false, }, deps, ); @@ -8275,6 +8278,8 @@ export function createTestCommand(deps: TestDeps = {}): Command { ) .option('--page-size ', 'with --history: number of runs per page (1–100, default 20)') .option('--cursor ', 'with --history: opaque cursor from a prior page') + .option('--columns ', 'with --history: select/reorder text table columns') + .option('--no-header', 'with --history: suppress the text table header row') .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (testId: string, cmdOpts: ResultFlagOpts, command: Command) => { if (cmdOpts.history) { @@ -8290,6 +8295,8 @@ export function createTestCommand(deps: TestDeps = {}): Command { ? parseNumericFlag(cmdOpts.pageSize, 'page-size') : undefined, cursor: cmdOpts.cursor, + columns: cmdOpts.columns, + noHeader: cmdOpts.header === false, }, deps, ); @@ -9042,6 +9049,8 @@ interface ResultFlagOpts { pageSize?: string; /** Opaque pagination cursor from a prior page's nextCursor. */ cursor?: string; + columns?: string; + header?: boolean; } interface CreateFlagOpts { @@ -9089,6 +9098,8 @@ interface ListFlagOpts { */ cursor?: string; maxItems?: string; + columns?: string; + header?: boolean; } interface StepsFlagOpts { @@ -9435,45 +9446,36 @@ async function streamPresignedBody(url: string, out: Output, deps: TestDeps): Pr } } -function renderTestListText(page: Page): string { +const TEST_LIST_COLUMNS: ReadonlyArray> = [ + { + header: 'ID', + width: rows => Math.max(2, ...rows.map(test => test.id.length)), + render: test => test.id, + }, + { + header: 'NAME', + width: rows => Math.max(4, ...rows.map(test => test.name.length)), + render: test => test.name, + }, + { header: 'TYPE', width: 8, render: test => test.type }, + { header: 'FROM', width: 6, render: test => test.createdFrom }, + { header: 'STATUS', width: 9, render: test => test.status }, + { header: 'UPDATED', width: 0, render: test => test.updatedAt }, +]; + +function renderTestListText( + page: Page, + options: { columns?: string; noHeader?: boolean } = {}, +): string { if (page.items.length === 0) { return page.nextToken ? `No tests on this page.\nnextToken: ${page.nextToken}` : 'No tests.'; } - const idWidth = Math.max(2, ...page.items.map(t => t.id.length)); - const nameWidth = Math.max(4, ...page.items.map(t => t.name.length)); - const typeWidth = 8; - const fromWidth = 6; - const statusWidth = 9; - - const header = - pad('ID', idWidth) + - ' ' + - pad('NAME', nameWidth) + - ' ' + - pad('TYPE', typeWidth) + - ' ' + - pad('FROM', fromWidth) + - ' ' + - pad('STATUS', statusWidth) + - ' ' + - 'UPDATED'; - - const rows = page.items.map( - t => - pad(t.id, idWidth) + - ' ' + - pad(t.name, nameWidth) + - ' ' + - pad(t.type, typeWidth) + - ' ' + - pad(t.createdFrom, fromWidth) + - ' ' + - pad(t.status, statusWidth) + - ' ' + - t.updatedAt, - ); - - const lines = [header, ...rows]; + const lines = [ + renderTextTable(page.items, TEST_LIST_COLUMNS, { + columns: options.columns, + noHeader: options.noHeader, + }), + ]; if (page.nextToken) lines.push('', `nextToken: ${page.nextToken}`); return lines.join('\n'); } diff --git a/src/lib/text-table.ts b/src/lib/text-table.ts new file mode 100644 index 0000000..bd66b5d --- /dev/null +++ b/src/lib/text-table.ts @@ -0,0 +1,94 @@ +import { localValidationError } from './errors.js'; + +export interface TextTableColumn { + header: string; + width: number | ((rows: readonly T[]) => number); + render: (row: T) => string; +} + +export interface TextTableOptions { + columns?: string; + noHeader?: boolean; + separator?: boolean; +} + +export function renderTextTable( + rows: readonly T[], + columns: readonly TextTableColumn[], + options: TextTableOptions = {}, +): string { + const selected = resolveTextColumns(options.columns, columns); + const widths = measureTextColumns(rows, selected); + const body = rows.map(row => + formatTextTableRow( + selected.map(column => column.render(row)), + widths, + ), + ); + + if (options.noHeader === true) return body.join('\n'); + + const header = formatTextTableRow( + selected.map(column => column.header), + widths, + ); + return [header, ...(options.separator === true ? ['-'.repeat(header.length)] : []), ...body].join( + '\n', + ); +} + +export function resolveTextColumns( + raw: string | undefined, + columns: readonly TextTableColumn[], +): readonly TextTableColumn[] { + if (raw === undefined || raw.trim() === '') return columns; + + const byKey = new Map(columns.map(column => [textColumnKey(column.header), column])); + const validKeys = columns.map(column => textColumnKey(column.header)); + const requested = raw.split(',').map(token => token.trim()); + + if (requested.some(token => token.length === 0)) { + throw localValidationError( + 'columns', + `must be a comma-separated list of: ${validKeys.join(', ')}`, + validKeys, + ); + } + + return requested.map(token => { + const key = textColumnKey(token); + const column = byKey.get(key); + if (column === undefined) { + throw localValidationError( + 'columns', + `unknown column "${token}"; must be one of: ${validKeys.join(', ')}`, + validKeys, + ); + } + return column; + }); +} + +export function measureTextColumns( + rows: readonly T[], + columns: readonly TextTableColumn[], +): number[] { + return columns.map(column => + typeof column.width === 'function' ? column.width(rows) : column.width, + ); +} + +export function formatTextTableRow(values: readonly string[], widths: readonly number[]): string { + return values + .map((value, index) => (index === values.length - 1 ? value : pad(value, widths[index] ?? 0))) + .join(' '); +} + +function textColumnKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function pad(value: string, width: number): string { + if (value.length >= width) return value; + return value + ' '.repeat(width - value.length); +} diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 62efe5d..2dc1121 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -187,6 +187,9 @@ Options: --page-size service page-size hint (1-100, default 25) --starting-token opaque cursor from a previous list response --max-items stop after this many items across auto-paged pages + --columns select/reorder text table columns (comma-separated + keys) + --no-header suppress the text table header row -h, --help display help for command Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): @@ -444,6 +447,9 @@ Options: --cursor alias for --starting-token; accepted for parity with \`test result --history\` --max-items stop after this many items across auto-paged pages + --columns select/reorder text table columns (comma-separated + keys) + --no-header suppress the text table header row -h, --help display help for command Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): @@ -548,6 +554,8 @@ Options: --page-size with --history: number of runs per page (1–100, default 20) --cursor with --history: opaque cursor from a prior page + --columns with --history: select/reorder text table columns + --no-header with --history: suppress the text table header row -h, --help display help for command Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): From 5321839b32aaa28fc09c455ac133897e4e57836b Mon Sep 17 00:00:00 2001 From: nopp Date: Thu, 16 Jul 2026 14:47:43 +0700 Subject: [PATCH 2/2] fix(cli): address list column review feedback --- src/commands/project.test.ts | 9 ++---- src/commands/project.ts | 5 +++- src/commands/test.result.history.spec.ts | 36 ++++++++++++++++++++++-- src/commands/test.test.ts | 9 ++---- src/commands/test.ts | 10 +++++-- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index 6462a6d..20227aa 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -310,12 +310,7 @@ describe('runList', () => { expect(block).not.toContain('CREATED'); }); - it('text output rejects unknown columns with VALIDATION_ERROR', async () => { - const { credentialsPath } = makeCreds(); - const fetchImpl = makeFetch(() => ({ - body: { items: [PROJECT_FIXTURE], nextToken: null }, - })); - + it('text output rejects unknown columns with VALIDATION_ERROR before auth/network access', async () => { await expect( runList( { @@ -325,7 +320,7 @@ describe('runList', () => { pageSize: 25, columns: 'bogus', }, - { credentialsPath, fetchImpl, stdout: () => undefined }, + { stdout: () => undefined }, ), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', diff --git a/src/commands/project.ts b/src/commands/project.ts index 8f92b21..3a0be75 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -12,7 +12,7 @@ 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 { assertNotLocal } from '../lib/target-url.js'; -import { renderTextTable, type TextTableColumn } from '../lib/text-table.js'; +import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js'; import { assertIdempotencyKey } from '../lib/validate.js'; import { fetchSinglePage, @@ -60,6 +60,9 @@ export async function runList( startingToken: opts.startingToken, maxItems: opts.maxItems, }); + if (opts.output === 'text') { + resolveTextColumns(opts.columns, PROJECT_LIST_COLUMNS); + } const client = makeClient(opts, deps); // When the user explicitly passed a page-size flag and did NOT ask diff --git a/src/commands/test.result.history.spec.ts b/src/commands/test.result.history.spec.ts index ed0c43d..87df3e0 100644 --- a/src/commands/test.result.history.spec.ts +++ b/src/commands/test.result.history.spec.ts @@ -264,15 +264,45 @@ describe('runResultHistory — text mode', () => { expect(output).not.toContain('targetUrl:'); }); - it('rejects unknown history columns with VALIDATION_ERROR', async () => { + it('no-header suppresses only the history header and separator', async () => { const { credentialsPath } = makeCreds(); + const lines: string[] = []; const fetchImpl = makeFetch(url => { if (url.includes('/tests/test_abc/runs')) { - return { body: makeHistoryResp() }; + return { + body: makeHistoryResp([ + makeHistoryItem({ + runId: 'run_url_001', + targetUrl: 'https://staging.example.com/checkout', + targetUrlSource: 'run', + }), + ]), + }; } return { status: 404, body: errorEnvelope('NOT_FOUND') }; }); + await runResultHistory( + { + output: 'text', + testId: 'test_abc', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + noHeader: true, + }, + { credentialsPath, fetchImpl, stdout: line => lines.push(line) }, + ); + + const output = lines.join('\n'); + expect(output).not.toMatch(/^RUN ID/m); + expect(output).not.toMatch(/^-+$/m); + expect(output).toContain('run_url_001'); + expect(output).toContain('targetUrl: https://staging.example.com/checkout'); + }); + + it('rejects unknown history columns with VALIDATION_ERROR before auth/network access', async () => { await expect( runResultHistory( { @@ -284,7 +314,7 @@ describe('runResultHistory — text mode', () => { verbose: false, columns: 'bogus', }, - { credentialsPath, fetchImpl, stdout: () => undefined }, + { stdout: () => undefined }, ), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 1a3e6a3..bb37922 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -603,12 +603,7 @@ describe('runList', () => { expect(out.join('\n')).not.toContain('UPDATED'); }); - it('text mode rejects unknown columns with VALIDATION_ERROR', async () => { - const { credentialsPath } = makeCreds(); - const fetchImpl = makeFetch(() => ({ - body: { items: [FE_TEST], nextToken: null }, - })); - + it('text mode rejects unknown columns with VALIDATION_ERROR before auth/network access', async () => { await expect( runList( { @@ -619,7 +614,7 @@ describe('runList', () => { pageSize: 25, columns: 'bogus', }, - { credentialsPath, fetchImpl, stdout: () => undefined }, + { stdout: () => undefined }, ), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', diff --git a/src/commands/test.ts b/src/commands/test.ts index c296064..5292187 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -505,6 +505,9 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise

JSON.stringify(data)); return resp; } @@ -4524,7 +4530,7 @@ function renderRunHistoryTable( const selectedColumns = resolveTextColumns(options.columns, RUN_HISTORY_TABLE_COLUMNS); const widths = measureTextColumns(runs, selectedColumns); const customColumns = options.columns !== undefined && options.columns.trim() !== ''; - const includeDetailLines = !customColumns && options.noHeader !== true; + const includeDetailLines = !customColumns; const header = formatTextTableRow( selectedColumns.map(column => column.header), widths,