Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ abs experiments list --metric 42 --type test,feature --state running -o ids
# Customize columns
abs experiments list --show experiment_report archived # add extra columns
abs experiments list --exclude primary_metric owner # hide columns
abs experiments list --started-after "3 months ago" \
--show-only id name primary_metric secondary_metrics guardrail_metrics exploratory_metrics

# Search by name
abs experiments search "onboarding"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@absmartly/cli",
"version": "1.15.0",
"version": "1.16.0",
"description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans",
"type": "module",
"main": "./dist/index.js",
Expand Down
24 changes: 22 additions & 2 deletions src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,27 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
})
);
await client.listExperiments({ created_after: 0 });
expect(receivedParams?.get('created_at')).toBe('0,0');
expect(receivedParams?.get('created_at')).toBe('0,');
});

// The API treats an empty bound as open-ended but a literal 0 as epoch 0,
// so a one-sided range must leave the missing side empty.
it('should leave the upper bound empty when only an after date is given', async () => {
let receivedParams: URLSearchParams | null = null;
server.use(
http.get(`${BASE_URL}/experiments`, ({ request }) => {
receivedParams = new URL(request.url).searchParams;
return HttpResponse.json({ experiments: [] });
})
);
await client.listExperiments({
created_after: 1000,
started_after: 2000,
stopped_after: 3000,
});
expect(receivedParams?.get('created_at')).toBe('1000,');
expect(receivedParams?.get('started_at')).toBe('2000,');
expect(receivedParams?.get('stopped_at')).toBe('3000,');
});

it('should pass started_at with only started_before', async () => {
Expand All @@ -295,7 +315,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
})
);
await client.listExperiments({ started_before: 1000 });
expect(receivedParams?.get('started_at')).toBe('0,1000');
expect(receivedParams?.get('started_at')).toBe(',1000');
});

it('should pass stopped_at with timestamp 0 for stopped_after', async () => {
Expand Down
12 changes: 9 additions & 3 deletions src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ function createAPIError(message: string, response?: HttpResponse): APIError {
return new APIError(message, response?.status, response?.data);
}

// The API reads an empty bound as open-ended but `0` as epoch 0, so a missing
// side must be left empty rather than defaulted to 0.
function dateRange(after?: number, before?: number): string {
return `${after ?? ''},${before ?? ''}`;
}

export class APIClient {
private httpClient: HttpClient;

Expand Down Expand Up @@ -195,16 +201,16 @@ export class APIClient {

if (options.created_at) params.created_at = options.created_at;
else if (options.created_after !== undefined || options.created_before !== undefined) {
params.created_at = `${options.created_after ?? 0},${options.created_before ?? 0}`;
params.created_at = dateRange(options.created_after, options.created_before);
}
if (options.updated_at) params.updated_at = options.updated_at;
if (options.started_at) params.started_at = options.started_at;
else if (options.started_after !== undefined || options.started_before !== undefined) {
params.started_at = `${options.started_after ?? 0},${options.started_before ?? 0}`;
params.started_at = dateRange(options.started_after, options.started_before);
}
if (options.stopped_at) params.stopped_at = options.stopped_at;
else if (options.stopped_after !== undefined || options.stopped_before !== undefined) {
params.stopped_at = `${options.stopped_after ?? 0},${options.stopped_before ?? 0}`;
params.stopped_at = dateRange(options.stopped_after, options.stopped_before);
}
if (options.full_on_at) params.full_on_at = options.full_on_at;

Expand Down
60 changes: 60 additions & 0 deletions src/api-client/experiment-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,66 @@ describe('summarizeExperimentRow', () => {
expect(row.percentages).toBe('50/50');
});

describe('per-role metric columns', () => {
const exp = {
...baseExperiment,
secondary_metrics: [
{ metric_id: 1, type: 'secondary', metric: { name: 'revenue' } },
{ metric_id: 2, type: 'guardrail', metric: { name: 'errors' } },
{ metric_id: 3, type: 'guardrail', metric: { name: 'latency' } },
{ metric_id: 4, type: 'exploratory', metric: { name: 'clicks' } },
{ metric_id: 5, metric: { name: 'untyped' } },
{ metric_id: 6, type: 'secondary' },
],
};

it('should not add metric role columns unless requested', () => {
const row = summarizeExperimentRow(exp);
expect(row).not.toHaveProperty('secondary_metrics');
expect(row).not.toHaveProperty('guardrail_metrics');
expect(row).not.toHaveProperty('exploratory_metrics');
});

it('should split secondary_metrics by role when requested via --show', () => {
const row = summarizeExperimentRow(exp, [
'secondary_metrics',
'guardrail_metrics',
'exploratory_metrics',
]);
expect(row.secondary_metrics).toEqual(['revenue', 'untyped', '6']);
expect(row.guardrail_metrics).toEqual(['errors', 'latency']);
expect(row.exploratory_metrics).toEqual(['clicks']);
});

it('should support metric role columns via --show-only', () => {
const row = summarizeExperimentRow(
exp,
[],
[],
['id', 'primary_metric', 'guardrail_metrics']
);
expect(row).toEqual({
id: 10,
primary_metric: 'ctr',
guardrail_metrics: ['errors', 'latency'],
});
});

it('should match metric role columns case-insensitively', () => {
const row = summarizeExperimentRow(exp, ['Guardrail_Metrics']);
expect(row.Guardrail_Metrics).toEqual(['errors', 'latency']);
});

it('should return empty arrays when the experiment has no metrics in a role', () => {
const row = summarizeExperimentRow(baseExperiment, [
'secondary_metrics',
'guardrail_metrics',
]);
expect(row.secondary_metrics).toEqual([]);
expect(row.guardrail_metrics).toEqual([]);
});
});

it('should include unit_type/traffic/owner when --show overrides defaults', () => {
const row = summarizeExperimentRow(baseExperiment, ['unit_type', 'traffic', 'owner']);
expect(row.unit_type).toBe('device_id');
Expand Down
51 changes: 31 additions & 20 deletions src/api-client/experiment-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import {
resolveDotPath,
} from './format-helpers.js';

const METRIC_ROLE_FIELDS = new Set([
'secondary_metrics',
'guardrail_metrics',
'exploratory_metrics',
]);

// `secondary_metrics` holds every non-primary metric, distinguished by `type`
// (empty means secondary). Groups metric names into `<type>_metrics` columns.
function metricNamesByRole(
metrics: Array<Record<string, unknown>> | undefined
): Map<string, string[]> {
const byRole = new Map<string, string[]>();
for (const m of metrics ?? []) {
const field = `${(m.type as string) || 'secondary'}_metrics`;
const name = String((m.metric as Record<string, unknown>)?.name ?? m.metric_id);
const names = byRole.get(field);
if (names) names.push(name);
else byRole.set(field, [name]);
}
return byRole;
}

export function summarizeExperiment(
exp: Record<string, unknown>,
extraFields: string[] = [],
Expand Down Expand Up @@ -38,26 +60,8 @@ export function summarizeExperiment(
percentages: exp.percentages ?? '',
};

if (secondaryMetrics && secondaryMetrics.length > 0) {
const secondary = secondaryMetrics.filter((m) => m.type === 'secondary' || !m.type);
const guardrail = secondaryMetrics.filter((m) => m.type === 'guardrail');
const exploratory = secondaryMetrics.filter((m) => m.type === 'exploratory');

if (secondary.length > 0) {
summary.secondary_metrics = secondary
.map((m) => (m.metric as Record<string, unknown>)?.name ?? m.metric_id)
.join(', ');
}
if (guardrail.length > 0) {
summary.guardrail_metrics = guardrail
.map((m) => (m.metric as Record<string, unknown>)?.name ?? m.metric_id)
.join(', ');
}
if (exploratory.length > 0) {
summary.exploratory_metrics = exploratory
.map((m) => (m.metric as Record<string, unknown>)?.name ?? m.metric_id)
.join(', ');
}
for (const [field, names] of metricNamesByRole(secondaryMetrics)) {
if (METRIC_ROLE_FIELDS.has(field)) summary[field] = names.join(', ');
}

const previewVariants = exp.preview_variants as Array<Record<string, unknown>> | undefined;
Expand Down Expand Up @@ -231,8 +235,15 @@ export function summarizeExperimentRow(
};

const lookupFields = onlyFields ? [...extraFields, ...onlyFields] : extraFields;
const roleNames = lookupFields.some((f) => METRIC_ROLE_FIELDS.has(f.toLowerCase()))
? metricNamesByRole(exp.secondary_metrics as Array<Record<string, unknown>> | undefined)
: undefined;
for (const field of lookupFields) {
if (field in row) continue;
if (roleNames && METRIC_ROLE_FIELDS.has(field.toLowerCase())) {
row[field] = roleNames.get(field.toLowerCase()) ?? [];
continue;
}
if (field in exp) {
row[field] = formatExtraField(field, exp[field]);
} else if (field.includes('.')) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/api/list-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe.skipIf(isLiveMode)('APIClient - List Options', () => {
created_after: 1704067200000,
});

expect(receivedParams?.get('created_at')).toBe('1704067200000,0');
expect(receivedParams?.get('created_at')).toBe('1704067200000,');
});

it('should handle partial date range with created_before only', async () => {
Expand All @@ -59,7 +59,7 @@ describe.skipIf(isLiveMode)('APIClient - List Options', () => {
created_before: 1735603200000,
});

expect(receivedParams?.get('created_at')).toBe('0,1735603200000');
expect(receivedParams?.get('created_at')).toBe(',1735603200000');
});

it('should send correct started_at date range', async () => {
Expand Down
43 changes: 43 additions & 0 deletions src/lib/output/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,49 @@ describe('Output Formatter', () => {
expect(result).toContain('value');
});

it('should include columns that are missing from the first row in a table', () => {
const rows = [
{ id: 1, name: 'a' },
{ id: 2, name: 'b', extra: 'only-here' },
];
const result = formatOutput(rows, 'table', { noColor: true });
expect(result).toContain('extra');
expect(result).toContain('only-here');
});

it('should include columns that are missing from the first row in markdown', () => {
const rows = [
{ id: 1, name: 'a' },
{ id: 2, name: 'b', extra: 'only-here' },
];
const result = formatOutput(rows, 'markdown', { noColor: true });
expect(result.split('\n')[0]).toBe('| id | name | extra |');
expect(result).toContain('| 2 | b | only-here |');
});

it('should align plain columns when rows have different keys', () => {
const rows = [
{ id: 1, extra: 'x' },
{ id: 2, name: 'b' },
];
const lines = formatOutput(rows, 'plain', { noColor: true }).split('\n');
expect(lines[0]!.split('\t')).toEqual(['1', 'x', '']);
expect(lines[1]!.split('\t')).toEqual(['2', '', 'b']);
});

it('should put array items on separate lines in table cells', () => {
const result = formatOutput([{ id: 1, metrics: ['a', 'b'] }], 'table', { noColor: true });
expect(result).toMatch(/│ a\s+│/);
expect(result).toMatch(/│ b\s+│/);
expect(result).not.toContain('a, b');
});

it('should keep array items comma-joined in plain and markdown', () => {
const rows = [{ id: 1, metrics: ['a', 'b'] }];
expect(formatOutput(rows, 'plain', { noColor: true })).toBe('1\ta, b');
expect(formatOutput(rows, 'markdown', { noColor: true })).toContain('| 1 | a, b |');
});

it('should format as markdown', () => {
const result = formatOutput(testData, 'markdown');
expect(result).toContain('|');
Expand Down
28 changes: 23 additions & 5 deletions src/lib/output/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ export function formatOutput(
}
}

// Rows can carry different optional fields (e.g. --show columns absent on some
// rows), so columns come from every row, in first-seen order.
function unionKeys(rows: unknown[]): string[] {
const keys = new Set<string>();
for (const row of rows) {
if (typeof row === 'object' && row !== null) {
for (const key of Object.keys(row)) keys.add(key);
}
}
return [...keys];
}

export function formatTable(data: unknown, options: OutputOptions = {}): string {
if (Array.isArray(data)) {
if (data.length === 0) {
Expand All @@ -97,7 +109,7 @@ export function formatTable(data: unknown, options: OutputOptions = {}): string
return formatPlain(data, options);
}

const keys = Object.keys(firstItem);
const keys = unionKeys(data);
const table = new Table({
head: keys.map((k) => (options.noColor ? k : chalk.bold(k))),
style: {
Expand Down Expand Up @@ -137,11 +149,12 @@ export function formatTable(data: unknown, options: OutputOptions = {}): string

export function formatPlain(data: unknown, options: OutputOptions = {}): string {
if (Array.isArray(data)) {
const keys = unionKeys(data);
return data
.map((item) => {
if (typeof item === 'object' && item !== null) {
return Object.values(item)
.map((v) => formatValue(v, options))
return keys
.map((key) => formatValue((item as Record<string, unknown>)[key], options))
.join('\t');
}
return formatValue(item, options);
Expand All @@ -167,7 +180,7 @@ export function formatMarkdown(data: unknown, options: OutputOptions = {}): stri
return data.map((item) => formatValue(item, options)).join('\n');
}

const keys = Object.keys(firstItem);
const keys = unionKeys(data);
let output = '| ' + keys.join(' | ') + ' |\n';
output += '| ' + keys.map(() => '---').join(' | ') + ' |\n';

Expand Down Expand Up @@ -264,7 +277,12 @@ export function formatValue(value: unknown, options: OutputOptions = {}): string
}
return text;
}
if (Array.isArray(value)) return value.map((v) => formatValue(v, options)).join(', ');
if (Array.isArray(value)) {
// One item per line in cell-based formats keeps multi-value columns narrow;
// line-oriented formats (plain, markdown) need them on a single line.
const separator = options.format === 'table' || options.format === 'vertical' ? '\n' : ', ';
return value.map((v) => formatValue(v, options)).join(separator);
}
if (isObject(value)) {
const summary = summarizeObjectValue(value);
if (summary !== null) return summary;
Expand Down
Loading