UNPKG

apisurf

Version:

Analyze API surface changes between npm package versions to catch breaking changes

528 lines (527 loc) 22.5 kB
import { describe, it, expect } from '@jest/globals'; import { compareTypeDefinitions } from './compareTypeDefinitions.js'; describe('compareTypeDefinitions', () => { it('should detect removed types as breaking changes', () => { const base = new Map([ ['RemovedType', { name: 'RemovedType', kind: 'interface', signature: 'interface RemovedType {}' }], ['KeptType', { name: 'KeptType', kind: 'interface', signature: 'interface KeptType {}' }] ]); const head = new Map([ ['KeptType', { name: 'KeptType', kind: 'interface', signature: 'interface KeptType {}' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'export-removed', description: expect.stringContaining('Removed interface'), before: 'RemovedType' }); }); it('should detect added types as non-breaking changes', () => { const base = new Map([ ['ExistingType', { name: 'ExistingType', kind: 'interface', signature: 'interface ExistingType {}' }] ]); const head = new Map([ ['ExistingType', { name: 'ExistingType', kind: 'interface', signature: 'interface ExistingType {}' }], ['NewType', { name: 'NewType', kind: 'type', signature: 'type NewType = string' }] ]); const result = compareTypeDefinitions(base, head); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0]).toEqual({ type: 'export-added', description: expect.stringContaining('Added type'), details: 'NewType' }); }); it('should detect function parameter removal as breaking change', () => { const base = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string, b: number): void', parameters: ['a: string', 'b: number'], returnType: 'void' }] ]); const head = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string): void', parameters: ['a: string'], returnType: 'void' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'parameter-changed', description: expect.stringContaining('removed parameters'), before: 'a: string, b: number', after: 'a: string' }); }); it('should detect required parameter addition as breaking change', () => { const base = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string): void', parameters: ['a: string'], returnType: 'void' }] ]); const head = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string, b: number): void', parameters: ['a: string', 'b: number'], returnType: 'void' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'parameter-changed', description: expect.stringContaining('added required parameters'), before: 'a: string', after: 'a: string, b: number' }); }); it('should detect optional parameter addition as non-breaking change', () => { const base = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string): void', parameters: ['a: string'], returnType: 'void' }] ]); const head = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string, b?: number): void', parameters: ['a: string', 'b?: number'], returnType: 'void' }] ]); const result = compareTypeDefinitions(base, head); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0]).toEqual({ type: 'parameter-added', description: expect.stringContaining('added optional parameters'), details: 'b?: number' }); }); it('should detect parameter type changes as breaking change', () => { const base = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: string): void', parameters: ['a: string'], returnType: 'void' }] ]); const head = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(a: number): void', parameters: ['a: number'], returnType: 'void' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'parameter-changed', description: expect.stringContaining('changed parameter 1 type'), before: 'a: string', after: 'a: number' }); }); it('should detect return type changes as breaking change', () => { const base = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(): string', parameters: [], returnType: 'string' }] ]); const head = new Map([ ['testFunction', { name: 'testFunction', kind: 'function', signature: 'function testFunction(): number', parameters: [], returnType: 'number' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'type-changed', description: expect.stringContaining('changed return type'), before: 'string', after: 'number' }); }); it('should detect interface property removal as breaking change', () => { const base = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; prop2: number; }', properties: new Map([ ['prop1', 'string'], ['prop2', 'number'] ]) }] ]); const head = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; }', properties: new Map([ ['prop1', 'string'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'type-changed', description: expect.stringContaining('Changed interface'), before: 'interface TestInterface { prop1: string; prop2: number; }', after: 'interface TestInterface { prop1: string; }' }); }); it('should detect adding optional properties to class as non-breaking change', () => { const base = new Map([ ['ServerSessionExtensionConfig', { name: 'ServerSessionExtensionConfig', kind: 'class', signature: 'export class ServerSessionExtensionConfig { }', properties: new Map() }] ]); const head = new Map([ ['ServerSessionExtensionConfig', { name: 'ServerSessionExtensionConfig', kind: 'class', signature: 'export class ServerSessionExtensionConfig { readonly coauthVersionXrevId?: string; readonly coauthVersionXluid?: string; readonly coauthVersionDocId?: string; }', properties: new Map([ ['coauthVersionXrevId', 'string'], ['coauthVersionXluid', 'string'], ['coauthVersionDocId', 'string'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('Updated class'); expect(result.nonBreakingChanges[0].description).toContain('non-breaking changes'); }); it('should detect adding optional property to interface as non-breaking change', () => { const base = new Map([ ['IMockWorksheet', { name: 'IMockWorksheet', kind: 'interface', signature: 'interface IMockWorksheet { id: string; }', properties: new Map([ ['id', 'string'] ]) }] ]); const head = new Map([ ['IMockWorksheet', { name: 'IMockWorksheet', kind: 'interface', signature: 'interface IMockWorksheet { id: string; worksheetName?: string; }', properties: new Map([ ['id', 'string'], ['worksheetName', 'string'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('Updated interface'); expect(result.nonBreakingChanges[0].description).toContain('non-breaking changes'); }); it('should detect optional interface property addition as non-breaking change', () => { const base = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; }', properties: new Map([ ['prop1', 'string'] ]) }] ]); const head = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; prop2?: number; }', properties: new Map([ ['prop1', 'string'], ['prop2', '?: number'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0]).toEqual({ type: 'type-updated', description: expect.stringContaining('Updated interface'), details: 'interface TestInterface { prop1: string; prop2?: number; }' }); }); it('should detect required interface property addition as breaking change', () => { const base = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; }', properties: new Map([ ['prop1', 'string'] ]) }] ]); const head = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; prop2: number; }', properties: new Map([ ['prop1', 'string'], ['prop2', 'number'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('Changed interface'); }); it('should detect adding required property to class as breaking change', () => { const base = new Map([ ['UserClass', { name: 'UserClass', kind: 'class', signature: 'class UserClass { name: string; }', properties: new Map([ ['name', 'string'] ]) }] ]); const head = new Map([ ['UserClass', { name: 'UserClass', kind: 'class', signature: 'class UserClass { name: string; email: string; }', properties: new Map([ ['name', 'string'], ['email', 'string'] // Required property (no ?) ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.nonBreakingChanges).toHaveLength(0); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('Changed class'); }); it('should detect interface property type changes as breaking change', () => { const base = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: string; }', properties: new Map([ ['prop1', 'string'] ]) }] ]); const head = new Map([ ['TestInterface', { name: 'TestInterface', kind: 'interface', signature: 'interface TestInterface { prop1: number; }', properties: new Map([ ['prop1', 'number'] ]) }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'type-changed', description: expect.stringContaining('Changed interface'), before: 'interface TestInterface { prop1: string; }', after: 'interface TestInterface { prop1: number; }' }); }); it('should handle generic type signature changes', () => { const base = new Map([ ['GenericType', { name: 'GenericType', kind: 'type', signature: 'type GenericType = string' }] ]); const head = new Map([ ['GenericType', { name: 'GenericType', kind: 'type', signature: 'type GenericType = number' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0]).toEqual({ type: 'type-changed', description: expect.stringContaining('Changed type'), before: 'type GenericType = string', after: 'type GenericType = number' }); }); it('should handle empty type definition maps', () => { const base = new Map(); const head = new Map(); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(0); }); it('should handle functions without parameters or properties', () => { const base = new Map([ ['simpleFunction', { name: 'simpleFunction', kind: 'function', signature: 'function simpleFunction(): void', parameters: [], returnType: 'void' }] ]); const head = new Map([ ['simpleFunction', { name: 'simpleFunction', kind: 'function', signature: 'function simpleFunction(): string', parameters: [], returnType: 'string' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); }); it('should detect adding enum values as non-breaking change', () => { const base = new Map([ ['ErrorCode', { name: 'ErrorCode', kind: 'enum', signature: 'enum ErrorCode { NetworkError = 1, TimeoutError = 2 }' }] ]); const head = new Map([ ['ErrorCode', { name: 'ErrorCode', kind: 'enum', signature: 'enum ErrorCode { NetworkError = 1, TimeoutError = 2, NullRestURL = 3 }' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('Updated enum'); expect(result.nonBreakingChanges[0].description).toContain('non-breaking changes'); }); it('should detect removing enum values as breaking change', () => { const base = new Map([ ['ErrorCode', { name: 'ErrorCode', kind: 'enum', signature: 'enum ErrorCode { NetworkError = 1, TimeoutError = 2, OldError = 3 }' }] ]); const head = new Map([ ['ErrorCode', { name: 'ErrorCode', kind: 'enum', signature: 'enum ErrorCode { NetworkError = 1, TimeoutError = 2 }' }] ]); const result = compareTypeDefinitions(base, head); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('Changed enum'); }); it('should correctly handle ExcelErrorCode enum with NullRestURL addition', () => { const base = new Map([ ['ExcelErrorCode', { name: 'ExcelErrorCode', kind: 'enum', signature: 'enum ExcelErrorCode { NetworkError = 1, TimeoutError = 2 }' }] ]); const head = new Map([ ['ExcelErrorCode', { name: 'ExcelErrorCode', kind: 'enum', signature: 'enum ExcelErrorCode { NetworkError = 1, TimeoutError = 2, NullRestURL = 3 }' }] ]); const result = compareTypeDefinitions(base, head); // Should be non-breaking since we're only adding a value expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('Updated enum'); expect(result.nonBreakingChanges[0].description).toContain('non-breaking changes'); }); it('should handle multi-line enums with added values as non-breaking', () => { const base = new Map([ ['ExcelErrorCode', { name: 'ExcelErrorCode', kind: 'enum', signature: `export enum ExcelErrorCode { NetworkError = 1, TimeoutError = 2 }` }] ]); const head = new Map([ ['ExcelErrorCode', { name: 'ExcelErrorCode', kind: 'enum', signature: `export enum ExcelErrorCode { NetworkError = 1, TimeoutError = 2, NullRestURL = 3 }` }] ]); const result = compareTypeDefinitions(base, head); // Should be non-breaking since we're only adding a value expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('Updated enum'); expect(result.nonBreakingChanges[0].description).toContain('non-breaking changes'); }); });