Skip to content

Commit 4189f1f

Browse files
feat(cli): add text list column controls
1 parent 60d55e4 commit 4189f1f

7 files changed

Lines changed: 474 additions & 129 deletions

File tree

src/commands/project.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ describe('createProjectCommand', () => {
8787
expect(flagNames).toContain('--page-size');
8888
expect(flagNames).toContain('--starting-token');
8989
expect(flagNames).toContain('--max-items');
90+
expect(flagNames).toContain('--columns');
91+
expect(flagNames).toContain('--no-header');
9092
});
9193
});
9294

@@ -283,6 +285,77 @@ describe('runList', () => {
283285
expect(block).toContain('nextToken: next-please');
284286
});
285287

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

src/commands/project.ts

Lines changed: 36 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, 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(
@@ -84,7 +87,7 @@ export async function runList(
8487

8588
out.print(page, data => {
8689
const p = data as Page<CliProject>;
87-
return renderProjectListText(p);
90+
return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader });
8891
});
8992
return page;
9093
}
@@ -584,6 +587,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command {
584587
.option('--page-size <n>', 'service page-size hint (1-100, default 25)')
585588
.option('--starting-token <token>', 'opaque cursor from a previous list response')
586589
.option('--max-items <n>', 'stop after this many items across auto-paged pages')
590+
.option('--columns <list>', 'select/reorder text table columns (comma-separated keys)')
591+
.option('--no-header', 'suppress the text table header row')
587592
.addHelpText('after', GLOBAL_OPTS_HINT)
588593
.action(async (cmdOpts: ListFlagOpts, command: Command) => {
589594
// Don't parse numeric flags via Commander — its parser throws a
@@ -597,6 +602,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command {
597602
pageSize: parseFlag(cmdOpts.pageSize, 'page-size'),
598603
startingToken: cmdOpts.startingToken,
599604
maxItems: parseFlag(cmdOpts.maxItems, 'max-items'),
605+
columns: cmdOpts.columns,
606+
noHeader: cmdOpts.header === false,
600607
},
601608
deps,
602609
);
@@ -784,6 +791,8 @@ interface ListFlagOpts {
784791
pageSize?: string;
785792
startingToken?: string;
786793
maxItems?: string;
794+
columns?: string;
795+
header?: boolean;
787796
}
788797

789798
interface CreateFlagOpts {
@@ -885,45 +894,37 @@ function makeOutput(mode: OutputMode, deps: ProjectDeps): Output {
885894
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
886895
}
887896

888-
function renderProjectListText(page: Page<CliProject>): string {
897+
const PROJECT_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliProject>> = [
898+
{
899+
header: 'ID',
900+
width: rows => Math.max(2, ...rows.map(project => project.id.length)),
901+
render: project => project.id,
902+
},
903+
{
904+
header: 'NAME',
905+
width: rows => Math.max(4, ...rows.map(project => project.name.length)),
906+
render: project => project.name,
907+
},
908+
{ header: 'TYPE', width: 8, render: project => project.type },
909+
{ header: 'FROM', width: 6, render: project => project.createdFrom },
910+
{ header: 'CREATED', width: 0, render: project => project.createdAt },
911+
];
912+
913+
function renderProjectListText(
914+
page: Page<CliProject>,
915+
options: { columns?: string; noHeader?: boolean } = {},
916+
): string {
889917
if (page.items.length === 0) {
890918
return page.nextToken
891919
? `No projects on this page.\nnextToken: ${page.nextToken}`
892920
: 'No projects.';
893921
}
894-
// Compact, AWS-CLI-grade columnar output. Column widths are computed
895-
// per-call so a single absurdly long project name doesn't push the
896-
// whole table off-screen.
897-
const idWidth = Math.max(2, ...page.items.map(p => p.id.length));
898-
const nameWidth = Math.max(4, ...page.items.map(p => p.name.length));
899-
const typeWidth = 8;
900-
const fromWidth = 6;
901-
902-
const header =
903-
pad('ID', idWidth) +
904-
' ' +
905-
pad('NAME', nameWidth) +
906-
' ' +
907-
pad('TYPE', typeWidth) +
908-
' ' +
909-
pad('FROM', fromWidth) +
910-
' ' +
911-
'CREATED';
912-
913-
const rows = page.items.map(
914-
p =>
915-
pad(p.id, idWidth) +
916-
' ' +
917-
pad(p.name, nameWidth) +
918-
' ' +
919-
pad(p.type, typeWidth) +
920-
' ' +
921-
pad(p.createdFrom, fromWidth) +
922-
' ' +
923-
p.createdAt,
924-
);
925-
926-
const lines = [header, ...rows];
922+
const lines = [
923+
renderTextTable(page.items, PROJECT_LIST_COLUMNS, {
924+
columns: options.columns,
925+
noHeader: options.noHeader,
926+
}),
927+
];
927928
if (page.nextToken) lines.push('', `nextToken: ${page.nextToken}`);
928929
return lines.join('\n');
929930
}
@@ -939,11 +940,6 @@ function renderProjectText(p: CliProject): string {
939940
].join('\n');
940941
}
941942

942-
function pad(s: string, width: number): string {
943-
if (s.length >= width) return s;
944-
return s + ' '.repeat(width - s.length);
945-
}
946-
947943
function renderUpdateText(r: CliUpdateProjectResponse): string {
948944
return [
949945
`id: ${r.id}`,

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,102 @@ 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('rejects unknown history columns with VALIDATION_ERROR', async () => {
268+
const { credentialsPath } = makeCreds();
269+
const fetchImpl = makeFetch(url => {
270+
if (url.includes('/tests/test_abc/runs')) {
271+
return { body: makeHistoryResp() };
272+
}
273+
return { status: 404, body: errorEnvelope('NOT_FOUND') };
274+
});
275+
276+
await expect(
277+
runResultHistory(
278+
{
279+
output: 'text',
280+
testId: 'test_abc',
281+
profile: 'default',
282+
dryRun: false,
283+
debug: false,
284+
verbose: false,
285+
columns: 'bogus',
286+
},
287+
{ credentialsPath, fetchImpl, stdout: () => undefined },
288+
),
289+
).rejects.toMatchObject({
290+
code: 'VALIDATION_ERROR',
291+
exitCode: 5,
292+
details: { field: 'columns' },
293+
});
294+
});
295+
296+
it('json mode ignores text-only history column flags', async () => {
297+
const { credentialsPath } = makeCreds();
298+
const lines: string[] = [];
299+
const fetchImpl = makeFetch(url => {
300+
if (url.includes('/tests/test_abc/runs')) {
301+
return { body: makeHistoryResp() };
302+
}
303+
return { status: 404, body: errorEnvelope('NOT_FOUND') };
304+
});
305+
306+
await runResultHistory(
307+
{
308+
output: 'json',
309+
testId: 'test_abc',
310+
profile: 'default',
311+
dryRun: false,
312+
debug: false,
313+
verbose: false,
314+
columns: 'bogus',
315+
noHeader: true,
316+
},
317+
{ credentialsPath, fetchImpl, stdout: line => lines.push(line) },
318+
);
319+
320+
const parsed = JSON.parse(lines.join('')) as { runs: Array<{ runId: string }> };
321+
expect(parsed.runs[0]?.runId).toBe('run_hist_001');
322+
});
323+
228324
it('renders run_hist_001 row with status passed and source cli', async () => {
229325
const { credentialsPath } = makeCreds();
230326
const lines: string[] = [];

0 commit comments

Comments
 (0)