diff --git a/__tests__/changeset-formatter.test.ts b/__tests__/changeset-formatter.test.ts index 16c0485..7e673d8 100644 --- a/__tests__/changeset-formatter.test.ts +++ b/__tests__/changeset-formatter.test.ts @@ -463,4 +463,314 @@ describe('Change Set Formatter', () => { ) expect(markdown).toContain('⚠️ Requires recreation: Always') }) + + test('diffs Tags arrays correctly', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyParameter', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Tags', + RequiresRecreation: 'Never', + BeforeValue: JSON.stringify([ + { Key: 'Version', Value: 'v1' }, + { Key: 'Team', Value: 'DevOps' }, + { Key: 'Environment', Value: 'test' } + ]), + AfterValue: JSON.stringify([ + { Key: 'Version', Value: 'v2' }, + { Key: 'UpdateType', Value: 'InPlace' }, + { Key: 'Environment', Value: 'production' } + ]) + } + } + ] + } + } + ], + totalChanges: 1, + truncated: false + }) + + const markdown = generateChangeSetMarkdown(changesSummary) + + expect(markdown).toContain('**Tags:**') + expect(markdown).toContain('**Tags.Environment:** `test` → `production`') + expect(markdown).toContain('**Tags.Team:** `DevOps` → (removed)') + expect(markdown).toContain('**Tags.UpdateType:** (added) → `InPlace`') + expect(markdown).toContain('**Tags.Version:** `v1` → `v2`') + }) + + test('diffs nested objects correctly', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyResource', + ResourceType: 'AWS::Custom::Resource', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Config', + RequiresRecreation: 'Never', + BeforeValue: JSON.stringify({ + Setting: 'old', + Nested: { Value: 'a' } + }), + AfterValue: JSON.stringify({ + Setting: 'new', + Nested: { Value: 'b' } + }) + } + } + ] + } + } + ], + totalChanges: 1, + truncated: false + }) + + const markdown = generateChangeSetMarkdown(changesSummary) + + expect(markdown).toContain('**Config.Setting:** `old` → `new`') + expect(markdown).toContain('**Config.Nested.Value:** `a` → `b`') + }) + + test('handles generic arrays as JSON strings', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyResource', + ResourceType: 'AWS::Custom::Resource', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Items', + RequiresRecreation: 'Never', + BeforeValue: JSON.stringify(['a', 'b']), + AfterValue: JSON.stringify(['a', 'c']) + } + } + ] + } + } + ], + totalChanges: 1, + truncated: false + }) + + const markdown = generateChangeSetMarkdown(changesSummary) + + expect(markdown).toContain('**Items:**') + expect(markdown).toContain('["a","b"]') + expect(markdown).toContain('["a","c"]') + }) + + test('handles array additions and removals', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyResource', + ResourceType: 'AWS::Custom::Resource', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'NewList', + RequiresRecreation: 'Never', + AfterValue: JSON.stringify(['x', 'y']) + } + }, + { + Target: { + Attribute: 'Properties', + Name: 'OldList', + RequiresRecreation: 'Never', + BeforeValue: JSON.stringify(['a', 'b']) + } + } + ] + } + } + ], + totalChanges: 1, + truncated: false + }) + + const markdown = generateChangeSetMarkdown(changesSummary) + + expect(markdown).toContain('**NewList:** (added) → `["x","y"]`') + expect(markdown).toContain('**OldList:** `["a","b"]` → (removed)') + }) + + test('handles primitive value additions and removals', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyResource', + ResourceType: 'AWS::Custom::Resource', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'NewProp', + RequiresRecreation: 'Never', + AfterValue: 'new-value' + } + }, + { + Target: { + Attribute: 'Properties', + Name: 'OldProp', + RequiresRecreation: 'Never', + BeforeValue: 'old-value' + } + } + ] + } + } + ], + totalChanges: 1, + truncated: false + }) + + const markdown = generateChangeSetMarkdown(changesSummary) + + expect(markdown).toContain('**NewProp:** (added) → `new-value`') + expect(markdown).toContain('**OldProp:** `old-value` → (removed)') + }) + + test('displays AfterContext for Add actions in console output', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'NewBucket', + ResourceType: 'AWS::S3::Bucket', + AfterContext: + '{"BucketName":"my-bucket","Versioning":{"Status":"Enabled"}}' + } + } + ], + totalChanges: 1, + truncated: false + }) + + displayChangeSet(changesSummary, 1, true) + + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('Properties:') + ) + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('BucketName') + ) + }) + + test('displays BeforeContext for Remove actions in console output', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + LogicalResourceId: 'OldBucket', + ResourceType: 'AWS::S3::Bucket', + BeforeContext: '{"BucketName":"old-bucket"}' + } + } + ], + totalChanges: 1, + truncated: false + }) + + displayChangeSet(changesSummary, 1, true) + + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('Properties:') + ) + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('BucketName') + ) + }) + + test('handles invalid JSON in AfterContext gracefully', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'NewResource', + ResourceType: 'AWS::Custom::Resource', + AfterContext: 'invalid-json{' + } + } + ], + totalChanges: 1, + truncated: false + }) + + displayChangeSet(changesSummary, 1, true) + + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('invalid-json{') + ) + }) + + test('handles invalid JSON in BeforeContext gracefully', () => { + const changesSummary = JSON.stringify({ + changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + LogicalResourceId: 'OldResource', + ResourceType: 'AWS::Custom::Resource', + BeforeContext: 'invalid-json{' + } + } + ], + totalChanges: 1, + truncated: false + }) + + displayChangeSet(changesSummary, 1, true) + + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('invalid-json{') + ) + }) }) diff --git a/dist/index.js b/dist/index.js index a163cc4..09f0b82 100644 --- a/dist/index.js +++ b/dist/index.js @@ -59733,6 +59733,75 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.displayChangeSet = displayChangeSet; exports.generateChangeSetMarkdown = generateChangeSetMarkdown; const core = __importStar(__nccwpck_require__(7484)); +/** + * Recursively diff JSON values and return formatted changes + */ +function diffJson(before, after, path = '') { + var _a, _b; + const lines = []; + // Handle arrays + if (Array.isArray(before) || Array.isArray(after)) { + const beforeArr = Array.isArray(before) ? before : []; + const afterArr = Array.isArray(after) ? after : []; + // Special case: array of {Key, Value} objects (Tags) + if (((_a = beforeArr[0]) === null || _a === void 0 ? void 0 : _a.Key) || ((_b = afterArr[0]) === null || _b === void 0 ? void 0 : _b.Key)) { + const beforeMap = new Map(beforeArr.map((t) => [t.Key, t.Value])); + const afterMap = new Map(afterArr.map((t) => [t.Key, t.Value])); + const allKeys = new Set([...beforeMap.keys(), ...afterMap.keys()]); + for (const key of Array.from(allKeys).sort()) { + const b = beforeMap.get(key); + const a = afterMap.get(key); + const prefix = path ? `${path}.${key}` : key; + if (b && a && b !== a) + lines.push(` - **${prefix}:** \`${b}\` → \`${a}\``); + else if (a && !b) + lines.push(` - **${prefix}:** (added) → \`${a}\``); + else if (b && !a) + lines.push(` - **${prefix}:** \`${b}\` → (removed)`); + } + return lines; + } + // Generic array - show as JSON if different + if (JSON.stringify(beforeArr) !== JSON.stringify(afterArr)) { + if (beforeArr.length && afterArr.length) { + lines.push(` - **${path || 'value'}:** \`${JSON.stringify(beforeArr)}\` → \`${JSON.stringify(afterArr)}\``); + } + else if (afterArr.length) { + lines.push(` - **${path || 'value'}:** (added) → \`${JSON.stringify(afterArr)}\``); + } + else if (beforeArr.length) { + lines.push(` - **${path || 'value'}:** \`${JSON.stringify(beforeArr)}\` → (removed)`); + } + } + return lines; + } + // Handle objects + if ((before && typeof before === 'object') || + (after && typeof after === 'object')) { + const beforeObj = before; + const afterObj = after; + const allKeys = new Set([ + ...Object.keys(beforeObj || {}), + ...Object.keys(afterObj || {}) + ]); + for (const key of allKeys) { + const newPath = path ? `${path}.${key}` : key; + lines.push(...diffJson(beforeObj === null || beforeObj === void 0 ? void 0 : beforeObj[key], afterObj === null || afterObj === void 0 ? void 0 : afterObj[key], newPath)); + } + return lines; + } + // Primitives + if (before !== after) { + const prefix = path || 'value'; + if (before && after) + lines.push(` - **${prefix}:** \`${before}\` → \`${after}\``); + else if (after) + lines.push(` - **${prefix}:** (added) → \`${after}\``); + else if (before) + lines.push(` - **${prefix}:** \`${before}\` → (removed)`); + } + return lines; +} /** * ANSI color codes */ @@ -60052,14 +60121,52 @@ function generateChangeSetMarkdown(changesSummary) { if (!target) continue; const propName = target.Name || target.Attribute || 'Unknown'; - if (target.BeforeValue && target.AfterValue) { - markdown += `- **${propName}:** \`${target.BeforeValue}\` → \`${target.AfterValue}\`\n`; + // Try to parse as JSON and diff + let diffLines = []; + try { + const before = target.BeforeValue + ? JSON.parse(target.BeforeValue) + : undefined; + const after = target.AfterValue + ? JSON.parse(target.AfterValue) + : undefined; + diffLines = diffJson(before, after, propName); } - else if (target.AfterValue) { - markdown += `- **${propName}:** (added) → \`${target.AfterValue}\`\n`; + catch (_c) { + // Not JSON, use simple string diff + if (target.BeforeValue && target.AfterValue) { + diffLines = [ + `- **${propName}:** \`${target.BeforeValue}\` → \`${target.AfterValue}\`` + ]; + } + else if (target.AfterValue) { + diffLines = [ + `- **${propName}:** (added) → \`${target.AfterValue}\`` + ]; + } + else if (target.BeforeValue) { + diffLines = [ + `- **${propName}:** \`${target.BeforeValue}\` → (removed)` + ]; + } } - else if (target.BeforeValue) { - markdown += `- **${propName}:** \`${target.BeforeValue}\` → (removed)\n`; + // Output the diff lines + if (diffLines.length > 0) { + if (diffLines.length === 1 && diffLines[0].startsWith(' - ')) { + // Single nested line from diffJson - promote to top level + markdown += `- ${diffLines[0].substring(4)}\n`; + } + else if (diffLines.length === 1) { + // Single line already formatted + markdown += `${diffLines[0]}\n`; + } + else { + // Multiple lines - show property name and indent children + markdown += `- **${propName}:**\n`; + diffLines.forEach(line => { + markdown += `${line}\n`; + }); + } } if (target.RequiresRecreation && target.RequiresRecreation !== 'Never') { @@ -60076,7 +60183,7 @@ function generateChangeSetMarkdown(changesSummary) { markdown += JSON.stringify(afterProps, null, 2); markdown += '\n```\n'; } - catch (_c) { + catch (_d) { // Skip if can't parse } } @@ -60088,7 +60195,7 @@ function generateChangeSetMarkdown(changesSummary) { markdown += JSON.stringify(beforeProps, null, 2); markdown += '\n```\n'; } - catch (_d) { + catch (_e) { // Skip if can't parse } } diff --git a/src/changeset-formatter.ts b/src/changeset-formatter.ts index a536353..655e5dd 100644 --- a/src/changeset-formatter.ts +++ b/src/changeset-formatter.ts @@ -16,6 +16,88 @@ interface ChangeSetSummary { truncated: boolean } +/** + * Recursively diff JSON values and return formatted changes + */ +function diffJson(before: unknown, after: unknown, path = ''): string[] { + const lines: string[] = [] + + // Handle arrays + if (Array.isArray(before) || Array.isArray(after)) { + const beforeArr = Array.isArray(before) ? before : [] + const afterArr = Array.isArray(after) ? after : [] + + // Special case: array of {Key, Value} objects (Tags) + if (beforeArr[0]?.Key || afterArr[0]?.Key) { + const beforeMap = new Map( + beforeArr.map((t: { Key: string; Value: string }) => [t.Key, t.Value]) + ) + const afterMap = new Map( + afterArr.map((t: { Key: string; Value: string }) => [t.Key, t.Value]) + ) + const allKeys = new Set([...beforeMap.keys(), ...afterMap.keys()]) + + for (const key of Array.from(allKeys).sort()) { + const b = beforeMap.get(key) + const a = afterMap.get(key) + const prefix = path ? `${path}.${key}` : key + if (b && a && b !== a) + lines.push(` - **${prefix}:** \`${b}\` → \`${a}\``) + else if (a && !b) lines.push(` - **${prefix}:** (added) → \`${a}\``) + else if (b && !a) lines.push(` - **${prefix}:** \`${b}\` → (removed)`) + } + return lines + } + + // Generic array - show as JSON if different + if (JSON.stringify(beforeArr) !== JSON.stringify(afterArr)) { + if (beforeArr.length && afterArr.length) { + lines.push( + ` - **${path || 'value'}:** \`${JSON.stringify(beforeArr)}\` → \`${JSON.stringify(afterArr)}\`` + ) + } else if (afterArr.length) { + lines.push( + ` - **${path || 'value'}:** (added) → \`${JSON.stringify(afterArr)}\`` + ) + } else if (beforeArr.length) { + lines.push( + ` - **${path || 'value'}:** \`${JSON.stringify(beforeArr)}\` → (removed)` + ) + } + } + return lines + } + + // Handle objects + if ( + (before && typeof before === 'object') || + (after && typeof after === 'object') + ) { + const beforeObj = before as Record + const afterObj = after as Record + const allKeys = new Set([ + ...Object.keys(beforeObj || {}), + ...Object.keys(afterObj || {}) + ]) + for (const key of allKeys) { + const newPath = path ? `${path}.${key}` : key + lines.push(...diffJson(beforeObj?.[key], afterObj?.[key], newPath)) + } + return lines + } + + // Primitives + if (before !== after) { + const prefix = path || 'value' + if (before && after) + lines.push(` - **${prefix}:** \`${before}\` → \`${after}\``) + else if (after) lines.push(` - **${prefix}:** (added) → \`${after}\``) + else if (before) lines.push(` - **${prefix}:** \`${before}\` → (removed)`) + } + + return lines +} + /** * ANSI color codes */ @@ -400,12 +482,48 @@ export function generateChangeSetMarkdown(changesSummary: string): string { const propName = target.Name || target.Attribute || 'Unknown' - if (target.BeforeValue && target.AfterValue) { - markdown += `- **${propName}:** \`${target.BeforeValue}\` → \`${target.AfterValue}\`\n` - } else if (target.AfterValue) { - markdown += `- **${propName}:** (added) → \`${target.AfterValue}\`\n` - } else if (target.BeforeValue) { - markdown += `- **${propName}:** \`${target.BeforeValue}\` → (removed)\n` + // Try to parse as JSON and diff + let diffLines: string[] = [] + try { + const before = target.BeforeValue + ? JSON.parse(target.BeforeValue) + : undefined + const after = target.AfterValue + ? JSON.parse(target.AfterValue) + : undefined + diffLines = diffJson(before, after, propName) + } catch { + // Not JSON, use simple string diff + if (target.BeforeValue && target.AfterValue) { + diffLines = [ + `- **${propName}:** \`${target.BeforeValue}\` → \`${target.AfterValue}\`` + ] + } else if (target.AfterValue) { + diffLines = [ + `- **${propName}:** (added) → \`${target.AfterValue}\`` + ] + } else if (target.BeforeValue) { + diffLines = [ + `- **${propName}:** \`${target.BeforeValue}\` → (removed)` + ] + } + } + + // Output the diff lines + if (diffLines.length > 0) { + if (diffLines.length === 1 && diffLines[0].startsWith(' - ')) { + // Single nested line from diffJson - promote to top level + markdown += `- ${diffLines[0].substring(4)}\n` + } else if (diffLines.length === 1) { + // Single line already formatted + markdown += `${diffLines[0]}\n` + } else { + // Multiple lines - show property name and indent children + markdown += `- **${propName}:**\n` + diffLines.forEach(line => { + markdown += `${line}\n` + }) + } } if (