diff --git a/README.md b/README.md index 5da6228..f3e8171 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/package.json b/package.json index d54e546..eb50c6f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index 6ba982c..7173f38 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -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 () => { @@ -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 () => { diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index c7466dc..f8eb64b 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -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; @@ -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; diff --git a/src/api-client/experiment-summary.test.ts b/src/api-client/experiment-summary.test.ts index b020e4d..3d97486 100644 --- a/src/api-client/experiment-summary.test.ts +++ b/src/api-client/experiment-summary.test.ts @@ -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'); diff --git a/src/api-client/experiment-summary.ts b/src/api-client/experiment-summary.ts index 8aaf627..acd3108 100644 --- a/src/api-client/experiment-summary.ts +++ b/src/api-client/experiment-summary.ts @@ -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 `_metrics` columns. +function metricNamesByRole( + metrics: Array> | undefined +): Map { + const byRole = new Map(); + for (const m of metrics ?? []) { + const field = `${(m.type as string) || 'secondary'}_metrics`; + const name = String((m.metric as Record)?.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, extraFields: string[] = [], @@ -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)?.name ?? m.metric_id) - .join(', '); - } - if (guardrail.length > 0) { - summary.guardrail_metrics = guardrail - .map((m) => (m.metric as Record)?.name ?? m.metric_id) - .join(', '); - } - if (exploratory.length > 0) { - summary.exploratory_metrics = exploratory - .map((m) => (m.metric as Record)?.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> | undefined; @@ -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> | 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('.')) { diff --git a/src/lib/api/list-options.test.ts b/src/lib/api/list-options.test.ts index 8637c57..94349e6 100644 --- a/src/lib/api/list-options.test.ts +++ b/src/lib/api/list-options.test.ts @@ -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 () => { @@ -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 () => { diff --git a/src/lib/output/formatter.test.ts b/src/lib/output/formatter.test.ts index ed161f1..64881a2 100644 --- a/src/lib/output/formatter.test.ts +++ b/src/lib/output/formatter.test.ts @@ -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('|'); diff --git a/src/lib/output/formatter.ts b/src/lib/output/formatter.ts index efdda02..0881724 100644 --- a/src/lib/output/formatter.ts +++ b/src/lib/output/formatter.ts @@ -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(); + 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) { @@ -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: { @@ -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)[key], options)) .join('\t'); } return formatValue(item, options); @@ -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'; @@ -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;