UNPKG

apisurf

Version:

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

255 lines (254 loc) 12.6 kB
import { describe, it, expect } from '@jest/globals'; import { compareApiSurfaces } from './compareApiSurfaces.js'; describe('compareApiSurfaces', () => { const createApiSurface = (overrides = {}) => ({ namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map(), ...overrides }); const mockPackage = { name: 'test-package', path: './test' }; it('should detect removed named exports as breaking changes', () => { const base = createApiSurface({ namedExports: new Set(['exportA', 'exportB']) }); const head = createApiSurface({ namedExports: new Set(['exportA']) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('export-removed'); expect(result.breakingChanges[0].before).toBe('exportB'); expect(result.breakingChanges[0].description).toMatch(/exportB/); }); it('should detect added named exports as non-breaking changes', () => { const base = createApiSurface({ namedExports: new Set(['exportA']) }); const head = createApiSurface({ namedExports: new Set(['exportA', 'exportB']) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('export-added'); expect(result.nonBreakingChanges[0].details).toBe('exportB'); expect(result.nonBreakingChanges[0].description).toMatch(/exportB/); }); it('should handle no changes', () => { const base = createApiSurface({ namedExports: new Set(['exportA']), typeOnlyExports: new Set(['TypeA']), defaultExport: true }); const head = createApiSurface({ namedExports: new Set(['exportA']), typeOnlyExports: new Set(['TypeA']), defaultExport: true }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(0); }); it('should not create duplicate removal entries for types with definitions', () => { const base = createApiSurface({ namedExports: new Set(['MyClass', 'myFunction']), typeOnlyExports: new Set(['MyInterface']), typeDefinitions: new Map([ ['MyClass', { name: 'MyClass', kind: 'class', signature: 'export class MyClass {}' }], ['MyInterface', { name: 'MyInterface', kind: 'interface', signature: 'export interface MyInterface {}' }] ]) }); const head = createApiSurface({ namedExports: new Set(), typeOnlyExports: new Set(), typeDefinitions: new Map() }); const result = compareApiSurfaces(base, head, mockPackage); // Should have 3 removals: MyClass (with type def), MyInterface (with type def), and myFunction (no type def) expect(result.breakingChanges).toHaveLength(3); // Check that we don't have duplicate entries for MyClass const myClassRemovals = result.breakingChanges.filter(change => change.description.includes('MyClass')); expect(myClassRemovals).toHaveLength(1); expect(myClassRemovals[0].description).toContain('Removed class'); // Check that we don't have duplicate entries for MyInterface const myInterfaceRemovals = result.breakingChanges.filter(change => change.description.includes('MyInterface')); expect(myInterfaceRemovals).toHaveLength(1); expect(myInterfaceRemovals[0].description).toContain('Removed interface'); // Check that myFunction (no type def) has generic removal message const myFunctionRemovals = result.breakingChanges.filter(change => change.description.includes('myFunction')); expect(myFunctionRemovals).toHaveLength(1); expect(myFunctionRemovals[0].description).toContain('Removed export'); }); describe('enum changes', () => { it('should detect removed enum values as breaking changes', () => { const base = createApiSurface({ typeOnlyExports: new Set(['OrderStatus']), typeDefinitions: new Map([ ['OrderStatus', { name: 'OrderStatus', kind: 'enum', members: ['PENDING', 'CONFIRMED', 'SHIPPED', 'CANCELLED'], signature: 'export enum OrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }' }] ]) }); const head = createApiSurface({ typeOnlyExports: new Set(['OrderStatus']), typeDefinitions: new Map([ ['OrderStatus', { name: 'OrderStatus', kind: 'enum', members: ['PENDING', 'CONFIRMED', 'SHIPPED'], // CANCELLED removed signature: 'export enum OrderStatus { PENDING, CONFIRMED, SHIPPED }' }] ]) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('OrderStatus'); }); it('should detect added enum values as non-breaking changes', () => { const base = createApiSurface({ typeOnlyExports: new Set(['OrderStatus']), typeDefinitions: new Map([ ['OrderStatus', { name: 'OrderStatus', kind: 'enum', members: ['PENDING', 'CONFIRMED', 'SHIPPED'], signature: 'export enum OrderStatus { PENDING, CONFIRMED, SHIPPED }' }] ]) }); const head = createApiSurface({ typeOnlyExports: new Set(['OrderStatus']), typeDefinitions: new Map([ ['OrderStatus', { name: 'OrderStatus', kind: 'enum', members: ['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED'], // DELIVERED added signature: 'export enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED }' }] ]) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('OrderStatus'); }); }); describe('interface changes', () => { it('should detect removed interface properties as breaking changes', () => { const base = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true }, { name: 'email', required: true } ], signature: 'export interface User { id: any; name: any; email: any }' }] ]) }); const head = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true } // email removed ], signature: 'export interface User { id: any; name: any }' }] ]) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('User'); }); it('should detect added required properties as breaking changes', () => { const base = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true } ], signature: 'export interface User { id: any; name: any }' }] ]) }); const head = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true }, { name: 'email', required: true } // required property added ], signature: 'export interface User { id: any; name: any; email: any }' }] ]) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(1); expect(result.breakingChanges[0].type).toBe('type-changed'); expect(result.breakingChanges[0].description).toContain('User'); }); it('should detect added optional properties as non-breaking changes', () => { const base = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true } ], signature: 'export interface User { id: any; name: any }' }] ]) }); const head = createApiSurface({ typeOnlyExports: new Set(['User']), typeDefinitions: new Map([ ['User', { name: 'User', kind: 'interface', extendedProperties: [ { name: 'id', required: true }, { name: 'name', required: true }, { name: 'email', required: false } // optional property added ], signature: 'export interface User { id: any; name: any; email?: any }' }] ]) }); const result = compareApiSurfaces(base, head, mockPackage); expect(result.breakingChanges).toHaveLength(0); expect(result.nonBreakingChanges).toHaveLength(1); expect(result.nonBreakingChanges[0].type).toBe('type-updated'); expect(result.nonBreakingChanges[0].description).toContain('User'); }); }); });