Skip to content
Open
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
68 changes: 68 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down Expand Up @@ -283,6 +285,72 @@ 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 before auth/network access', async () => {
await expect(
runList(
{
profile: 'default',
output: 'text',
debug: false,
pageSize: 25,
columns: 'bogus',
},
{ 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 } }));
Expand Down
79 changes: 39 additions & 40 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
import { assertIdempotencyKey } from '../lib/validate.js';
import {
fetchSinglePage,
Expand Down Expand Up @@ -44,6 +45,8 @@ interface ListOptions extends CommonOptions {
pageSize?: number;
startingToken?: string;
maxItems?: number;
columns?: string;
noHeader?: boolean;
}

export async function runList(
Expand All @@ -57,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
Expand Down Expand Up @@ -84,7 +90,7 @@ export async function runList(

out.print(page, data => {
const p = data as Page<CliProject>;
return renderProjectListText(p);
return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
return page;
}
Expand Down Expand Up @@ -584,6 +590,8 @@ export function createProjectCommand(deps: ProjectDeps = {}): Command {
.option('--page-size <n>', 'service page-size hint (1-100, default 25)')
.option('--starting-token <token>', 'opaque cursor from a previous list response')
.option('--max-items <n>', 'stop after this many items across auto-paged pages')
.option('--columns <list>', '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
Expand All @@ -597,6 +605,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,
);
Expand Down Expand Up @@ -784,6 +794,8 @@ interface ListFlagOpts {
pageSize?: string;
startingToken?: string;
maxItems?: string;
columns?: string;
header?: boolean;
}

interface CreateFlagOpts {
Expand Down Expand Up @@ -885,45 +897,37 @@ function makeOutput(mode: OutputMode, deps: ProjectDeps): Output {
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
}

function renderProjectListText(page: Page<CliProject>): string {
const PROJECT_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliProject>> = [
{
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<CliProject>,
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');
}
Expand All @@ -939,11 +943,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}`,
Expand Down
126 changes: 126 additions & 0 deletions src/commands/test.result.history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,132 @@ 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('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([
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(
{
output: 'text',
testId: 'test_abc',
profile: 'default',
dryRun: false,
debug: false,
verbose: false,
columns: 'bogus',
},
{ 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[] = [];
Expand Down
Loading
Loading