Skip to content

Commit c6cd26b

Browse files
authored
Add git-style diff view for CloudFormation change sets (#172)
- Replace property-by-property arrow notation with unified diff format - Use BeforeContext/AfterContext to show complete resource diffs - Display changes with +/- prefixes in diff code blocks - Add inline recreation warnings on properties that require replacement - Fix Tags display by using full context instead of individual Details - Consistent formatting across Add, Modify, and Remove actions
1 parent 7d5472f commit c6cd26b

3 files changed

Lines changed: 248 additions & 417 deletions

File tree

__tests__/changeset-formatter.test.ts

Lines changed: 104 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,16 @@ describe('Change Set Formatter', () => {
426426
ResourceType: 'AWS::DynamoDB::Table',
427427
Replacement: 'True',
428428
Scope: ['Properties'],
429+
BeforeContext: JSON.stringify({
430+
Properties: {
431+
BillingMode: 'PROVISIONED'
432+
}
433+
}),
434+
AfterContext: JSON.stringify({
435+
Properties: {
436+
BillingMode: 'PAY_PER_REQUEST'
437+
}
438+
}),
429439
Details: [
430440
{
431441
Target: {
@@ -458,164 +468,135 @@ describe('Change Set Formatter', () => {
458468
)
459469
expect(markdown).toContain('**Physical ID:** `my-table-123`')
460470
expect(markdown).toContain('⚠️ **This resource will be replaced**')
461-
expect(markdown).toContain(
462-
'**BillingMode:** `PROVISIONED` → `PAY_PER_REQUEST`'
463-
)
471+
expect(markdown).toContain('```diff')
464472
expect(markdown).toContain('⚠️ Requires recreation: Always')
465473
})
466474

467-
test('diffs Tags arrays correctly', () => {
475+
test('displays AfterContext for Add actions in console output', () => {
468476
const changesSummary = JSON.stringify({
469477
changes: [
470478
{
471479
Type: 'Resource',
472480
ResourceChange: {
473-
Action: 'Modify',
474-
LogicalResourceId: 'MyParameter',
475-
ResourceType: 'AWS::SSM::Parameter',
476-
Replacement: 'False',
477-
Scope: ['Properties'],
478-
Details: [
479-
{
480-
Target: {
481-
Attribute: 'Properties',
482-
Name: 'Tags',
483-
RequiresRecreation: 'Never',
484-
BeforeValue: JSON.stringify([
485-
{ Key: 'Version', Value: 'v1' },
486-
{ Key: 'Team', Value: 'DevOps' },
487-
{ Key: 'Environment', Value: 'test' }
488-
]),
489-
AfterValue: JSON.stringify([
490-
{ Key: 'Version', Value: 'v2' },
491-
{ Key: 'UpdateType', Value: 'InPlace' },
492-
{ Key: 'Environment', Value: 'production' }
493-
])
494-
}
495-
}
496-
]
481+
Action: 'Add',
482+
LogicalResourceId: 'NewBucket',
483+
ResourceType: 'AWS::S3::Bucket',
484+
AfterContext:
485+
'{"BucketName":"my-bucket","Versioning":{"Status":"Enabled"}}'
497486
}
498487
}
499488
],
500489
totalChanges: 1,
501490
truncated: false
502491
})
503492

504-
const markdown = generateChangeSetMarkdown(changesSummary)
493+
displayChangeSet(changesSummary, 1, true)
505494

506-
expect(markdown).toContain('**Tags:**')
507-
expect(markdown).toContain('**Tags.Environment:** `test` → `production`')
508-
expect(markdown).toContain('**Tags.Team:** `DevOps` → (removed)')
509-
expect(markdown).toContain('**Tags.UpdateType:** (added) → `InPlace`')
510-
expect(markdown).toContain('**Tags.Version:** `v1` → `v2`')
495+
expect(core.info).toHaveBeenCalledWith(
496+
expect.stringContaining('Properties:')
497+
)
498+
expect(core.info).toHaveBeenCalledWith(
499+
expect.stringContaining('BucketName')
500+
)
511501
})
512502

513-
test('diffs nested objects correctly', () => {
503+
test('displays BeforeContext for Remove actions in console output', () => {
514504
const changesSummary = JSON.stringify({
515505
changes: [
516506
{
517507
Type: 'Resource',
518508
ResourceChange: {
519-
Action: 'Modify',
520-
LogicalResourceId: 'MyResource',
509+
Action: 'Remove',
510+
LogicalResourceId: 'OldBucket',
511+
ResourceType: 'AWS::S3::Bucket',
512+
BeforeContext: '{"BucketName":"old-bucket"}'
513+
}
514+
}
515+
],
516+
totalChanges: 1,
517+
truncated: false
518+
})
519+
520+
displayChangeSet(changesSummary, 1, true)
521+
522+
expect(core.info).toHaveBeenCalledWith(
523+
expect.stringContaining('Properties:')
524+
)
525+
expect(core.info).toHaveBeenCalledWith(
526+
expect.stringContaining('BucketName')
527+
)
528+
})
529+
530+
test('handles invalid JSON in AfterContext gracefully', () => {
531+
const changesSummary = JSON.stringify({
532+
changes: [
533+
{
534+
Type: 'Resource',
535+
ResourceChange: {
536+
Action: 'Add',
537+
LogicalResourceId: 'NewResource',
521538
ResourceType: 'AWS::Custom::Resource',
522-
Replacement: 'False',
523-
Scope: ['Properties'],
524-
Details: [
525-
{
526-
Target: {
527-
Attribute: 'Properties',
528-
Name: 'Config',
529-
RequiresRecreation: 'Never',
530-
BeforeValue: JSON.stringify({
531-
Setting: 'old',
532-
Nested: { Value: 'a' }
533-
}),
534-
AfterValue: JSON.stringify({
535-
Setting: 'new',
536-
Nested: { Value: 'b' }
537-
})
538-
}
539-
}
540-
]
539+
AfterContext: 'invalid-json{'
541540
}
542541
}
543542
],
544543
totalChanges: 1,
545544
truncated: false
546545
})
547546

548-
const markdown = generateChangeSetMarkdown(changesSummary)
547+
displayChangeSet(changesSummary, 1, true)
549548

550-
expect(markdown).toContain('**Config.Setting:** `old` → `new`')
551-
expect(markdown).toContain('**Config.Nested.Value:** `a` → `b`')
549+
expect(core.info).toHaveBeenCalledWith(
550+
expect.stringContaining('invalid-json{')
551+
)
552552
})
553553

554-
test('handles generic arrays as JSON strings', () => {
554+
test('handles invalid JSON in BeforeContext gracefully', () => {
555555
const changesSummary = JSON.stringify({
556556
changes: [
557557
{
558558
Type: 'Resource',
559559
ResourceChange: {
560-
Action: 'Modify',
561-
LogicalResourceId: 'MyResource',
560+
Action: 'Remove',
561+
LogicalResourceId: 'OldResource',
562562
ResourceType: 'AWS::Custom::Resource',
563-
Replacement: 'False',
564-
Scope: ['Properties'],
565-
Details: [
566-
{
567-
Target: {
568-
Attribute: 'Properties',
569-
Name: 'Items',
570-
RequiresRecreation: 'Never',
571-
BeforeValue: JSON.stringify(['a', 'b']),
572-
AfterValue: JSON.stringify(['a', 'c'])
573-
}
574-
}
575-
]
563+
BeforeContext: 'invalid-json{'
576564
}
577565
}
578566
],
579567
totalChanges: 1,
580568
truncated: false
581569
})
582570

583-
const markdown = generateChangeSetMarkdown(changesSummary)
571+
displayChangeSet(changesSummary, 1, true)
584572

585-
expect(markdown).toContain('**Items:**')
586-
expect(markdown).toContain('["a","b"]')
587-
expect(markdown).toContain('["a","c"]')
573+
expect(core.info).toHaveBeenCalledWith(
574+
expect.stringContaining('invalid-json{')
575+
)
588576
})
589577

590-
test('handles array additions and removals', () => {
578+
test('generates diff view for resources with BeforeContext/AfterContext', () => {
591579
const changesSummary = JSON.stringify({
592580
changes: [
593581
{
594582
Type: 'Resource',
595583
ResourceChange: {
596584
Action: 'Modify',
597-
LogicalResourceId: 'MyResource',
598-
ResourceType: 'AWS::Custom::Resource',
585+
LogicalResourceId: 'MyTopic',
586+
ResourceType: 'AWS::SNS::Topic',
599587
Replacement: 'False',
600-
Scope: ['Properties'],
601-
Details: [
602-
{
603-
Target: {
604-
Attribute: 'Properties',
605-
Name: 'NewList',
606-
RequiresRecreation: 'Never',
607-
AfterValue: JSON.stringify(['x', 'y'])
608-
}
609-
},
610-
{
611-
Target: {
612-
Attribute: 'Properties',
613-
Name: 'OldList',
614-
RequiresRecreation: 'Never',
615-
BeforeValue: JSON.stringify(['a', 'b'])
616-
}
588+
BeforeContext: JSON.stringify({
589+
Properties: {
590+
DisplayName: 'old-name',
591+
Tags: [{ Key: 'Env', Value: 'dev' }]
617592
}
618-
]
593+
}),
594+
AfterContext: JSON.stringify({
595+
Properties: {
596+
DisplayName: 'new-name',
597+
Tags: [{ Key: 'Env', Value: 'prod' }]
598+
}
599+
})
619600
}
620601
}
621602
],
@@ -625,36 +606,38 @@ describe('Change Set Formatter', () => {
625606

626607
const markdown = generateChangeSetMarkdown(changesSummary)
627608

628-
expect(markdown).toContain('**NewList:** (added) → `["x","y"]`')
629-
expect(markdown).toContain('**OldList:** `["a","b"]` → (removed)')
609+
expect(markdown).toContain('```diff')
610+
expect(markdown).toContain('-')
611+
expect(markdown).toContain('+')
630612
})
631613

632-
test('handles primitive value additions and removals', () => {
614+
test('shows recreation warnings in diff view', () => {
633615
const changesSummary = JSON.stringify({
634616
changes: [
635617
{
636618
Type: 'Resource',
637619
ResourceChange: {
638620
Action: 'Modify',
639-
LogicalResourceId: 'MyResource',
640-
ResourceType: 'AWS::Custom::Resource',
641-
Replacement: 'False',
642-
Scope: ['Properties'],
621+
LogicalResourceId: 'MyParam',
622+
ResourceType: 'AWS::SSM::Parameter',
623+
Replacement: 'True',
624+
BeforeContext: JSON.stringify({
625+
Properties: {
626+
Name: '/old/path',
627+
Value: 'old'
628+
}
629+
}),
630+
AfterContext: JSON.stringify({
631+
Properties: {
632+
Name: '/new/path',
633+
Value: 'new'
634+
}
635+
}),
643636
Details: [
644637
{
645638
Target: {
646-
Attribute: 'Properties',
647-
Name: 'NewProp',
648-
RequiresRecreation: 'Never',
649-
AfterValue: 'new-value'
650-
}
651-
},
652-
{
653-
Target: {
654-
Attribute: 'Properties',
655-
Name: 'OldProp',
656-
RequiresRecreation: 'Never',
657-
BeforeValue: 'old-value'
639+
Name: 'Name',
640+
RequiresRecreation: 'Always'
658641
}
659642
}
660643
]
@@ -667,8 +650,8 @@ describe('Change Set Formatter', () => {
667650

668651
const markdown = generateChangeSetMarkdown(changesSummary)
669652

670-
expect(markdown).toContain('**NewProp:** (added) → `new-value`')
671-
expect(markdown).toContain('**OldProp:** `old-value` → (removed)')
653+
expect(markdown).toContain('```diff')
654+
expect(markdown).toContain('⚠️ Requires recreation: Always')
672655
})
673656

674657
test('displays AfterContext for Add actions in console output', () => {

0 commit comments

Comments
 (0)