diff --git a/src/commands/builds.js b/src/commands/builds.js index 137541d..900ac03 100644 --- a/src/commands/builds.js +++ b/src/commands/builds.js @@ -192,13 +192,52 @@ function formatBuildForJson(build, includeComparisons = false) { }; if (includeComparisons && build.comparisons) { - result.comparisonDetails = build.comparisons.map(c => ({ - id: c.id, - name: c.name, - status: c.status, - diffPercentage: c.diff_percentage, - approvalStatus: c.approval_status, - })); + result.comparisonDetails = build.comparisons.map(c => { + let diffUrl = c.diff_image?.url || c.diff_image_url || c.diff_url || null; + let diffImage = c.diff_image || {}; + let clusterMetadata = + c.cluster_metadata || diffImage.cluster_metadata || null; + let ssimScore = c.ssim_score ?? diffImage.ssim_score ?? null; + let gmsdScore = c.gmsd_score ?? diffImage.gmsd_score ?? null; + let fingerprintHash = + c.fingerprint_hash || diffImage.fingerprint_hash || null; + let hasHoneydiff = + clusterMetadata || + ssimScore != null || + gmsdScore != null || + fingerprintHash; + + return { + id: c.id, + name: c.name, + status: c.status, + diffPercentage: c.diff_percentage, + approvalStatus: c.approval_status, + urls: { + baseline: + c.baseline_screenshot?.original_url || + c.baseline_original_url || + c.baseline_screenshot_url || + null, + current: + c.current_screenshot?.original_url || + c.current_original_url || + c.current_screenshot_url || + null, + diff: diffUrl, + }, + honeydiff: hasHoneydiff + ? { + ssimScore, + gmsdScore, + clusterClassification: clusterMetadata?.classification || null, + clusterMetadata, + fingerprintHash, + diffRegions: c.diff_regions ?? diffImage.diff_regions ?? null, + } + : null, + }; + }); } return result; diff --git a/src/commands/comparisons.js b/src/commands/comparisons.js index e734c4b..6bef1ea 100644 --- a/src/commands/comparisons.js +++ b/src/commands/comparisons.js @@ -186,6 +186,24 @@ export async function comparisonsCommand( * Format a comparison for JSON output */ function formatComparisonForJson(comparison) { + // API endpoints return different shapes: + // - Single comparison: nested baseline_screenshot/current_screenshot + flat diff_url, honeydiff at top level + // - Build comparisons: flat diff_url/diff_image_url, no storage URLs, limited honeydiff + // - Search: nested diff_image with honeydiff, no current/baseline URLs + let diffImage = comparison.diff_image || {}; + let clusterMetadata = + comparison.cluster_metadata || diffImage.cluster_metadata || null; + let ssimScore = comparison.ssim_score ?? diffImage.ssim_score ?? null; + let gmsdScore = comparison.gmsd_score ?? diffImage.gmsd_score ?? null; + let fingerprintHash = + comparison.fingerprint_hash || diffImage.fingerprint_hash || null; + + let hasHoneydiff = + clusterMetadata || + ssimScore != null || + gmsdScore != null || + fingerprintHash; + return { id: comparison.id, name: comparison.name, @@ -197,10 +215,36 @@ function formatComparisonForJson(comparison) { : null, browser: comparison.browser || null, urls: { - baseline: comparison.baseline_screenshot?.original_url || null, - current: comparison.current_screenshot?.original_url || null, - diff: comparison.diff_image?.url || null, + baseline: + comparison.baseline_screenshot?.original_url || + comparison.baseline_original_url || + comparison.baseline_screenshot_url || + null, + current: + comparison.current_screenshot?.original_url || + comparison.current_original_url || + comparison.current_screenshot_url || + null, + diff: + comparison.diff_image?.url || + comparison.diff_image_url || + comparison.diff_url || + null, }, + honeydiff: hasHoneydiff + ? { + ssimScore, + gmsdScore, + clusterClassification: clusterMetadata?.classification || null, + clusterMetadata, + fingerprintHash, + diffRegions: + comparison.diff_regions ?? diffImage.diff_regions ?? null, + diffLines: comparison.diff_lines ?? diffImage.diff_lines ?? null, + fingerprintData: + comparison.fingerprint_data ?? diffImage.fingerprint_data ?? null, + } + : null, buildId: comparison.build_id, buildName: comparison.build_name, buildBranch: comparison.build_branch, @@ -251,22 +295,53 @@ function displayComparison(output, comparison, verbose) { output.labelValue('Commit', comparison.build_commit_sha.substring(0, 8)); } - // URLs in verbose mode + // Honeydiff analysis in verbose mode if (verbose) { - output.blank(); - output.labelValue('URLs', ''); - if (comparison.baseline_screenshot?.original_url) { - output.print( - ` Baseline: ${comparison.baseline_screenshot.original_url}` - ); - } - if (comparison.current_screenshot?.original_url) { - output.print( - ` Current: ${comparison.current_screenshot.original_url}` - ); + let clusterMetadata = + comparison.cluster_metadata || comparison.diff_image?.cluster_metadata; + let ssim = comparison.ssim_score ?? comparison.diff_image?.ssim_score; + let gmsd = comparison.gmsd_score ?? comparison.diff_image?.gmsd_score; + let fingerprint = + comparison.fingerprint_hash || comparison.diff_image?.fingerprint_hash; + + if (clusterMetadata || ssim != null || gmsd != null || fingerprint) { + output.blank(); + if (clusterMetadata?.classification) { + output.labelValue('Classification', clusterMetadata.classification); + } + if (ssim != null) { + output.labelValue('SSIM', ssim.toFixed(4)); + } + if (gmsd != null) { + output.labelValue('GMSD', gmsd.toFixed(4)); + } + if (fingerprint) { + output.labelValue('Fingerprint', fingerprint); + } } - if (comparison.diff_image?.url) { - output.print(` Diff: ${comparison.diff_image.url}`); + } + + // URLs in verbose mode + if (verbose) { + let baselineUrl = + comparison.baseline_screenshot?.original_url || + comparison.baseline_original_url || + comparison.baseline_screenshot_url; + let currentUrl = + comparison.current_screenshot?.original_url || + comparison.current_original_url || + comparison.current_screenshot_url; + let diffUrl = + comparison.diff_image?.url || + comparison.diff_image_url || + comparison.diff_url; + + if (baselineUrl || currentUrl || diffUrl) { + output.blank(); + output.labelValue('URLs', ''); + if (baselineUrl) output.print(` Baseline: ${baselineUrl}`); + if (currentUrl) output.print(` Current: ${currentUrl}`); + if (diffUrl) output.print(` Diff: ${diffUrl}`); } } @@ -312,7 +387,10 @@ function displayBuildComparisons(output, build, comparisons, verbose) { comp.diff_percentage != null ? colors.dim(` (${(comp.diff_percentage * 100).toFixed(1)}%)`) : ''; - output.print(` ${icon} ${comp.name}${diffInfo}`); + let classification = verbose + ? getClassificationLabel(colors, comp.cluster_metadata) + : ''; + output.print(` ${icon} ${comp.name}${diffInfo}${classification}`); } if (comparisons.length > (verbose ? 100 : 20)) { @@ -366,7 +444,13 @@ function displaySearchResults( for (let comp of group.comparisons.slice(0, verbose ? 10 : 3)) { let icon = getStatusIcon(colors, comp.status); - output.print(` ${icon} ${comp.name}`); + let classification = verbose + ? getClassificationLabel( + colors, + comp.cluster_metadata || comp.diff_image?.cluster_metadata + ) + : ''; + output.print(` ${icon} ${comp.name}${classification}`); } if (group.comparisons.length > (verbose ? 10 : 3)) { @@ -386,6 +470,15 @@ function displaySearchResults( } } +/** + * Get a classification label for verbose display + */ +function getClassificationLabel(colors, clusterMetadata) { + let classification = clusterMetadata?.classification; + if (!classification) return ''; + return colors.dim(` [${classification}]`); +} + /** * Get icon for comparison status */ diff --git a/tests/commands/builds.test.js b/tests/commands/builds.test.js index 678192d..680ea73 100644 --- a/tests/commands/builds.test.js +++ b/tests/commands/builds.test.js @@ -212,6 +212,80 @@ describe('commands/builds', () => { assert.strictEqual(capturedFilters.limit, 10); }); + it('includes URLs and honeydiff in comparisonDetails', async () => { + let output = createMockOutput(); + let mockBuild = { + id: 'build-1', + name: 'Build 1', + status: 'completed', + comparisons: [ + { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + diff_percentage: 0.025, + diff_image_url: 'https://cdn.example.com/diff.png', + cluster_metadata: { classification: 'minor', density: 0.05 }, + ssim_score: 0.9876, + gmsd_score: 0.0123, + fingerprint_hash: 'abc123', + diff_regions: [{ x: 10, y: 20 }], + }, + ], + }; + + await buildsCommand( + { build: 'build-1', comparisons: true }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getBuild: async () => ({ build: mockBuild }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let comp = dataCall.args[0].comparisonDetails[0]; + assert.strictEqual(comp.urls.diff, 'https://cdn.example.com/diff.png'); + assert.ok(comp.honeydiff, 'Should include honeydiff data'); + assert.strictEqual(comp.honeydiff.ssimScore, 0.9876); + assert.strictEqual(comp.honeydiff.clusterClassification, 'minor'); + assert.strictEqual(comp.honeydiff.fingerprintHash, 'abc123'); + }); + + it('sets honeydiff to null when no analysis data in comparisons', async () => { + let output = createMockOutput(); + let mockBuild = { + id: 'build-1', + name: 'Build 1', + status: 'completed', + comparisons: [ + { id: 'comp-1', name: 'button-primary', status: 'identical' }, + ], + }; + + await buildsCommand( + { build: 'build-1', comparisons: true }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getBuild: async () => ({ build: mockBuild }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let comp = dataCall.args[0].comparisonDetails[0]; + assert.strictEqual(comp.honeydiff, null); + assert.strictEqual(comp.urls.diff, null); + }); + it('passes project filter to API', async () => { let output = createMockOutput(); let capturedFilters = null; diff --git a/tests/commands/comparisons.test.js b/tests/commands/comparisons.test.js index a5430bd..d3b8926 100644 --- a/tests/commands/comparisons.test.js +++ b/tests/commands/comparisons.test.js @@ -280,5 +280,239 @@ describe('commands/comparisons', () => { assert.strictEqual(dataCall.args[0].id, 'comp-1'); assert.strictEqual(dataCall.args[0].name, 'button-primary'); }); + + it('includes honeydiff data in JSON output for single comparison', async () => { + let output = createMockOutput(); + let mockComparison = { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + diff_percentage: 0.025, + cluster_metadata: { + classification: 'minor', + density: 0.05, + distribution: 'localized', + }, + ssim_score: 0.9876, + gmsd_score: 0.0123, + fingerprint_hash: 'abc123def456', + diff_regions: [{ x: 10, y: 20, width: 50, height: 30 }], + diff_lines: [20, 30, 40], + fingerprint_data: { hash_components: [1, 2, 3] }, + }; + + await comparisonsCommand( + { id: 'comp-1' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getComparison: async () => mockComparison, + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let result = dataCall.args[0]; + assert.ok(result.honeydiff, 'Should include honeydiff data'); + assert.strictEqual(result.honeydiff.ssimScore, 0.9876); + assert.strictEqual(result.honeydiff.gmsdScore, 0.0123); + assert.strictEqual(result.honeydiff.clusterClassification, 'minor'); + assert.strictEqual(result.honeydiff.fingerprintHash, 'abc123def456'); + assert.deepStrictEqual(result.honeydiff.diffRegions, [ + { x: 10, y: 20, width: 50, height: 30 }, + ]); + assert.deepStrictEqual(result.honeydiff.diffLines, [20, 30, 40]); + }); + + it('includes honeydiff data from search results (nested in diff_image)', async () => { + let output = createMockOutput(); + let mockComparisons = [ + { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + build_id: 'b1', + diff_image: { + url: 'https://example.com/diff.png', + cluster_metadata: { + classification: 'dynamic_content', + density: 0.12, + }, + ssim_score: 0.9521, + gmsd_score: 0.0345, + fingerprint_hash: 'search-hash-789', + }, + }, + ]; + + await comparisonsCommand( + { name: 'button-*' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + searchComparisons: async () => ({ + comparisons: mockComparisons, + pagination: { total: 1, hasMore: false }, + }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let result = dataCall.args[0].comparisons[0]; + assert.ok(result.honeydiff, 'Should include honeydiff data'); + assert.strictEqual(result.honeydiff.ssimScore, 0.9521); + assert.strictEqual(result.honeydiff.gmsdScore, 0.0345); + assert.strictEqual( + result.honeydiff.clusterClassification, + 'dynamic_content' + ); + assert.strictEqual(result.honeydiff.fingerprintHash, 'search-hash-789'); + }); + + it('sets honeydiff to null when no analysis data present', async () => { + let output = createMockOutput(); + let mockComparison = { + id: 'comp-1', + name: 'button-primary', + status: 'identical', + }; + + await comparisonsCommand( + { id: 'comp-1' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getComparison: async () => mockComparison, + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + assert.strictEqual(dataCall.args[0].honeydiff, null); + }); + + it('resolves URLs from flat field names (single comparison endpoint)', async () => { + let output = createMockOutput(); + let mockComparison = { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + baseline_screenshot_url: 'https://cdn.example.com/baseline.png', + current_screenshot_url: 'https://cdn.example.com/current.png', + diff_url: 'https://cdn.example.com/diff.png', + }; + + await comparisonsCommand( + { id: 'comp-1' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getComparison: async () => mockComparison, + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let urls = dataCall.args[0].urls; + assert.strictEqual(urls.baseline, 'https://cdn.example.com/baseline.png'); + assert.strictEqual(urls.current, 'https://cdn.example.com/current.png'); + assert.strictEqual(urls.diff, 'https://cdn.example.com/diff.png'); + }); + + it('resolves URLs from build detail field names (diff_image_url)', async () => { + let output = createMockOutput(); + let mockBuild = { + id: 'build-1', + name: 'Build 1', + comparisons: [ + { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + diff_image_url: 'https://cdn.example.com/diff.png', + baseline_original_url: 'https://cdn.example.com/baseline.png', + current_original_url: 'https://cdn.example.com/current.png', + }, + ], + }; + + await comparisonsCommand( + { build: 'build-1' }, + { json: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getBuild: async () => ({ build: mockBuild }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall); + let urls = dataCall.args[0].comparisons[0].urls; + assert.strictEqual(urls.diff, 'https://cdn.example.com/diff.png'); + assert.strictEqual(urls.baseline, 'https://cdn.example.com/baseline.png'); + assert.strictEqual(urls.current, 'https://cdn.example.com/current.png'); + }); + + it('shows honeydiff analysis in verbose display', async () => { + let output = createMockOutput(); + let mockComparison = { + id: 'comp-1', + name: 'button-primary', + status: 'changed', + diff_percentage: 0.025, + cluster_metadata: { classification: 'minor' }, + ssim_score: 0.9876, + gmsd_score: 0.0123, + fingerprint_hash: 'abc123', + }; + + await comparisonsCommand( + { id: 'comp-1' }, + { verbose: true }, + { + loadConfig: async () => ({ apiKey: 'test-token' }), + createApiClient: () => ({}), + getComparison: async () => mockComparison, + output, + exit: () => {}, + } + ); + + let labelValues = output.calls + .filter(c => c.method === 'labelValue') + .map(c => c.args); + assert.ok( + labelValues.some(([label]) => label === 'Classification'), + 'Should show classification' + ); + assert.ok( + labelValues.some(([label]) => label === 'SSIM'), + 'Should show SSIM score' + ); + assert.ok( + labelValues.some(([label]) => label === 'GMSD'), + 'Should show GMSD score' + ); + assert.ok( + labelValues.some(([label]) => label === 'Fingerprint'), + 'Should show fingerprint' + ); + }); }); });