apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
528 lines (527 loc) • 22.5 kB
JavaScript
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');
});
});