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