Skip to content

Commit 9d4af09

Browse files
feat(cli): add text list column controls (#165) (#252)
* feat(cli): add text list column controls * fix(cli): address list column review feedback
1 parent 8db1882 commit 9d4af09

7 files changed

Lines changed: 504 additions & 130 deletions

File tree

src/commands/project.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ describe('createProjectCommand', () => {
8989
expect(flagNames).toContain('--page-size');
9090
expect(flagNames).toContain('--starting-token');
9191
expect(flagNames).toContain('--max-items');
92+
expect(flagNames).toContain('--columns');
93+
expect(flagNames).toContain('--no-header');
9294
});
9395
});
9496

@@ -285,6 +287,72 @@ describe('runList', () => {
285287
expect(block).toContain('nextToken: next-please');
286288
});
287289

290+
it('text output selects/reorders columns and suppresses the header', async () => {
291+
const { credentialsPath } = makeCreds();
292+
const fetchImpl = makeFetch(() => ({
293+
body: { items: [PROJECT_FIXTURE], nextToken: null },
294+
}));
295+
296+
const out: string[] = [];
297+
await runList(
298+
{
299+
profile: 'default',
300+
output: 'text',
301+
debug: false,
302+
pageSize: 25,
303+
columns: 'name,id',
304+
noHeader: true,
305+
},
306+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
307+
);
308+
309+
const block = out.join('\n');
310+
expect(block).toMatch(/^Checkout\s+project_b3c91efa$/);
311+
expect(block).not.toContain('NAME');
312+
expect(block).not.toContain('CREATED');
313+
});
314+
315+
it('text output rejects unknown columns with VALIDATION_ERROR before auth/network access', async () => {
316+
await expect(
317+
runList(
318+
{
319+
profile: 'default',
320+
output: 'text',
321+
debug: false,
322+
pageSize: 25,
323+
columns: 'bogus',
324+
},
325+
{ stdout: () => undefined },
326+
),
327+
).rejects.toMatchObject({
328+
code: 'VALIDATION_ERROR',
329+
exitCode: 5,
330+
details: { field: 'columns' },
331+
});
332+
});
333+
334+
it('json output ignores text-only column flags', async () => {
335+
const { credentialsPath } = makeCreds();
336+
const fetchImpl = makeFetch(() => ({
337+
body: { items: [PROJECT_FIXTURE], nextToken: null },
338+
}));
339+
340+
const out: string[] = [];
341+
await runList(
342+
{
343+
profile: 'default',
344+
output: 'json',
345+
debug: false,
346+
pageSize: 25,
347+
columns: 'bogus',
348+
noHeader: true,
349+
},
350+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
351+
);
352+
353+
expect(JSON.parse(out.join('\n')).items[0].id).toBe('project_b3c91efa');
354+
});
355+
288356
it('text output reads "No projects." when items is empty and nextToken is null', async () => {
289357
const { credentialsPath } = makeCreds();
290358
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));

src/commands/project.ts

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { FetchImpl } from '../lib/http.js';
1212
import type { HttpClient } from '../lib/http.js';
1313
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
1414
import { assertNotLocal } from '../lib/target-url.js';
15+
import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
1516
import { assertIdempotencyKey } from '../lib/validate.js';
1617
import {
1718
fetchSinglePage,
@@ -44,6 +45,8 @@ interface ListOptions extends CommonOptions {
4445
pageSize?: number;
4546
startingToken?: string;
4647
maxItems?: number;
48+
columns?: string;
49+
noHeader?: boolean;
4750
}
4851

4952
export async function runList(
@@ -57,6 +60,9 @@ export async function runList(
5760
startingToken: opts.startingToken,
5861
maxItems: opts.maxItems,
5962
});
63+
if (opts.output === 'text') {
64+
resolveTextColumns(opts.columns, PROJECT_LIST_COLUMNS);
65+
}
6066
const client = makeClient(opts, deps);
6167

6268
// When the user explicitly passed a page-size flag and did NOT ask
@@ -84,7 +90,7 @@ export async function runList(
8490

8591
out.print(page, data => {
8692
const p = data as Page<CliProject>;
87-
return renderProjectListText(p);
93+
return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader });
8894
});
8995
return page;
9096
}
@@ -663,6 +669,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command {
663669
.option('--page-size <n>', 'service page-size hint (1-100, default 25)')
664670
.option('--starting-token <token>', 'opaque cursor from a previous list response')
665671
.option('--max-items <n>', 'stop after this many items across auto-paged pages')
672+
.option('--columns <list>', 'select/reorder text table columns (comma-separated keys)')
673+
.option('--no-header', 'suppress the text table header row')
666674
.addHelpText('after', GLOBAL_OPTS_HINT)
667675
.action(async (cmdOpts: ListFlagOpts, command: Command) => {
668676
// Don't parse numeric flags via Commander — its parser throws a
@@ -676,6 +684,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command {
676684
pageSize: parseFlag(cmdOpts.pageSize, 'page-size'),
677685
startingToken: cmdOpts.startingToken,
678686
maxItems: parseFlag(cmdOpts.maxItems, 'max-items'),
687+
columns: cmdOpts.columns,
688+
noHeader: cmdOpts.header === false,
679689
},
680690
deps,
681691
);
@@ -895,6 +905,8 @@ interface ListFlagOpts {
895905
pageSize?: string;
896906
startingToken?: string;
897907
maxItems?: string;
908+
columns?: string;
909+
header?: boolean;
898910
}
899911

900912
interface CreateFlagOpts {
@@ -1001,45 +1013,37 @@ function makeOutput(mode: OutputMode, deps: ProjectDeps): Output {
10011013
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
10021014
}
10031015

1004-
function renderProjectListText(page: Page<CliProject>): string {
1016+
const PROJECT_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliProject>> = [
1017+
{
1018+
header: 'ID',
1019+
width: rows => Math.max(2, ...rows.map(project => project.id.length)),
1020+
render: project => project.id,
1021+
},
1022+
{
1023+
header: 'NAME',
1024+
width: rows => Math.max(4, ...rows.map(project => project.name.length)),
1025+
render: project => project.name,
1026+
},
1027+
{ header: 'TYPE', width: 8, render: project => project.type },
1028+
{ header: 'FROM', width: 6, render: project => project.createdFrom },
1029+
{ header: 'CREATED', width: 0, render: project => project.createdAt },
1030+
];
1031+
1032+
function renderProjectListText(
1033+
page: Page<CliProject>,
1034+
options: { columns?: string; noHeader?: boolean } = {},
1035+
): string {
10051036
if (page.items.length === 0) {
10061037
return page.nextToken
10071038
? `No projects on this page.\nnextToken: ${page.nextToken}`
10081039
: 'No projects.';
10091040
}
1010-
// Compact, AWS-CLI-grade columnar output. Column widths are computed
1011-
// per-call so a single absurdly long project name doesn't push the
1012-
// whole table off-screen.
1013-
const idWidth = Math.max(2, ...page.items.map(p => p.id.length));
1014-
const nameWidth = Math.max(4, ...page.items.map(p => p.name.length));
1015-
const typeWidth = 8;
1016-
const fromWidth = 6;
1017-
1018-
const header =
1019-
pad('ID', idWidth) +
1020-
' ' +
1021-
pad('NAME', nameWidth) +
1022-
' ' +
1023-
pad('TYPE', typeWidth) +
1024-
' ' +
1025-
pad('FROM', fromWidth) +
1026-
' ' +
1027-
'CREATED';
1028-
1029-
const rows = page.items.map(
1030-
p =>
1031-
pad(p.id, idWidth) +
1032-
' ' +
1033-
pad(p.name, nameWidth) +
1034-
' ' +
1035-
pad(p.type, typeWidth) +
1036-
' ' +
1037-
pad(p.createdFrom, fromWidth) +
1038-
' ' +
1039-
p.createdAt,
1040-
);
1041-
1042-
const lines = [header, ...rows];
1041+
const lines = [
1042+
renderTextTable(page.items, PROJECT_LIST_COLUMNS, {
1043+
columns: options.columns,
1044+
noHeader: options.noHeader,
1045+
}),
1046+
];
10431047
if (page.nextToken) lines.push('', `nextToken: ${page.nextToken}`);
10441048
return lines.join('\n');
10451049
}
@@ -1055,11 +1059,6 @@ function renderProjectText(p: CliProject): string {
10551059
].join('\n');
10561060
}
10571061

1058-
function pad(s: string, width: number): string {
1059-
if (s.length >= width) return s;
1060-
return s + ' '.repeat(width - s.length);
1061-
}
1062-
10631062
function renderUpdateText(r: CliUpdateProjectResponse): string {
10641063
return [
10651064
`id: ${r.id}`,

src/commands/test.result.history.spec.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,132 @@ describe('runResultHistory — text mode', () => {
225225
expect(output).toMatch(/DURATION/);
226226
});
227227

228+
it('selects/reorders columns and suppresses header/separator/detail sub-lines', async () => {
229+
const { credentialsPath } = makeCreds();
230+
const lines: string[] = [];
231+
const fetchImpl = makeFetch(url => {
232+
if (url.includes('/tests/test_abc/runs')) {
233+
return {
234+
body: makeHistoryResp([
235+
makeHistoryItem({
236+
runId: 'run_url_001',
237+
targetUrl: 'https://staging.example.com/checkout',
238+
targetUrlSource: 'run',
239+
}),
240+
]),
241+
};
242+
}
243+
return { status: 404, body: errorEnvelope('NOT_FOUND') };
244+
});
245+
246+
await runResultHistory(
247+
{
248+
output: 'text',
249+
testId: 'test_abc',
250+
profile: 'default',
251+
dryRun: false,
252+
debug: false,
253+
verbose: false,
254+
columns: 'status,runid',
255+
noHeader: true,
256+
},
257+
{ credentialsPath, fetchImpl, stdout: line => lines.push(line) },
258+
);
259+
260+
const output = lines.join('\n');
261+
expect(output.split('\n')[0]).toMatch(/^passed\s+run_url_001$/);
262+
expect(output).not.toMatch(/^RUN ID/m);
263+
expect(output).not.toMatch(/^-+$/m);
264+
expect(output).not.toContain('targetUrl:');
265+
});
266+
267+
it('no-header suppresses only the history header and separator', async () => {
268+
const { credentialsPath } = makeCreds();
269+
const lines: string[] = [];
270+
const fetchImpl = makeFetch(url => {
271+
if (url.includes('/tests/test_abc/runs')) {
272+
return {
273+
body: makeHistoryResp([
274+
makeHistoryItem({
275+
runId: 'run_url_001',
276+
targetUrl: 'https://staging.example.com/checkout',
277+
targetUrlSource: 'run',
278+
}),
279+
]),
280+
};
281+
}
282+
return { status: 404, body: errorEnvelope('NOT_FOUND') };
283+
});
284+
285+
await runResultHistory(
286+
{
287+
output: 'text',
288+
testId: 'test_abc',
289+
profile: 'default',
290+
dryRun: false,
291+
debug: false,
292+
verbose: false,
293+
noHeader: true,
294+
},
295+
{ credentialsPath, fetchImpl, stdout: line => lines.push(line) },
296+
);
297+
298+
const output = lines.join('\n');
299+
expect(output).not.toMatch(/^RUN ID/m);
300+
expect(output).not.toMatch(/^-+$/m);
301+
expect(output).toContain('run_url_001');
302+
expect(output).toContain('targetUrl: https://staging.example.com/checkout');
303+
});
304+
305+
it('rejects unknown history columns with VALIDATION_ERROR before auth/network access', async () => {
306+
await expect(
307+
runResultHistory(
308+
{
309+
output: 'text',
310+
testId: 'test_abc',
311+
profile: 'default',
312+
dryRun: false,
313+
debug: false,
314+
verbose: false,
315+
columns: 'bogus',
316+
},
317+
{ stdout: () => undefined },
318+
),
319+
).rejects.toMatchObject({
320+
code: 'VALIDATION_ERROR',
321+
exitCode: 5,
322+
details: { field: 'columns' },
323+
});
324+
});
325+
326+
it('json mode ignores text-only history column flags', async () => {
327+
const { credentialsPath } = makeCreds();
328+
const lines: string[] = [];
329+
const fetchImpl = makeFetch(url => {
330+
if (url.includes('/tests/test_abc/runs')) {
331+
return { body: makeHistoryResp() };
332+
}
333+
return { status: 404, body: errorEnvelope('NOT_FOUND') };
334+
});
335+
336+
await runResultHistory(
337+
{
338+
output: 'json',
339+
testId: 'test_abc',
340+
profile: 'default',
341+
dryRun: false,
342+
debug: false,
343+
verbose: false,
344+
columns: 'bogus',
345+
noHeader: true,
346+
},
347+
{ credentialsPath, fetchImpl, stdout: line => lines.push(line) },
348+
);
349+
350+
const parsed = JSON.parse(lines.join('')) as { runs: Array<{ runId: string }> };
351+
expect(parsed.runs[0]?.runId).toBe('run_hist_001');
352+
});
353+
228354
it('renders run_hist_001 row with status passed and source cli', async () => {
229355
const { credentialsPath } = makeCreds();
230356
const lines: string[] = [];

0 commit comments

Comments
 (0)