apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
240 lines (239 loc) • 10.5 kB
JavaScript
import { describe, it, expect, jest } from '@jest/globals';
// Create mock for execSync
const mockExecSync = jest.fn();
// Mock child_process before importing
jest.unstable_mockModule('child_process', () => ({
execSync: mockExecSync
}));
// Import after mocking
const { parseApiSurface } = await import('./parseApiSurface.js');
describe('parseApiSurface', () => {
it('should parse ES6 named exports', () => {
const source = `
export const foo = 'bar';
export function baz() {}
export class Test {}
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.namedExports.has('foo')).toBe(true);
expect(result.namedExports.has('baz')).toBe(true);
expect(result.namedExports.has('Test')).toBe(true);
});
it('should parse type-only exports', () => {
const source = `
export type { MyType, OtherType } from './types';
export interface MyInterface {}
export type MyTypeAlias = string;
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.typeOnlyExports.has('MyType')).toBe(true);
expect(result.typeOnlyExports.has('OtherType')).toBe(true);
expect(result.typeOnlyExports.has('MyInterface')).toBe(true);
expect(result.typeOnlyExports.has('MyTypeAlias')).toBe(true);
});
it('should detect default export', () => {
const source = `
export default function defaultFunc() {}
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.defaultExport).toBe(true);
});
it('should parse star exports', () => {
const source = `
export * from './module1';
export * from './module2';
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.starExports.includes('./module1')).toBe(true);
expect(result.starExports.includes('./module2')).toBe(true);
});
it('should handle mixed exports', () => {
const source = `
export { foo, type Bar, baz } from './module';
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.namedExports.has('foo')).toBe(true);
expect(result.namedExports.has('baz')).toBe(true);
expect(result.typeOnlyExports.has('Bar')).toBe(true);
});
describe('deep type analysis', () => {
it('should parse enum values', () => {
const source = `
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled'
}
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.typeOnlyExports.has('OrderStatus')).toBe(true);
// Should have enum definition details
const enumDef = result.typeDefinitions?.get('OrderStatus');
expect(enumDef).toBeDefined();
expect(enumDef?.kind).toBe('enum');
expect(enumDef?.members).toEqual([
'PENDING',
'CONFIRMED',
'SHIPPED',
'DELIVERED',
'CANCELLED'
]);
});
it('should parse interface properties', () => {
const source = `
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
isActive?: boolean;
}
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.typeOnlyExports.has('User')).toBe(true);
// Should have interface definition details
const interfaceDef = result.typeDefinitions?.get('User');
expect(interfaceDef).toBeDefined();
expect(interfaceDef?.kind).toBe('interface');
expect(interfaceDef?.extendedProperties).toEqual([
{ name: 'id', required: true },
{ name: 'name', required: true },
{ name: 'email', required: true },
{ name: 'createdAt', required: true },
{ name: 'isActive', required: false }
]);
});
it('should parse type alias definitions', () => {
const source = `
export type ApiResponse<T> = {
data: T;
status: 'success' | 'error';
message?: string;
};
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.typeOnlyExports.has('ApiResponse')).toBe(true);
// Should have type alias definition details
const typeDef = result.typeDefinitions?.get('ApiResponse');
expect(typeDef).toBeDefined();
expect(typeDef?.kind).toBe('type');
expect(typeDef?.extendedProperties).toEqual([
{ name: 'data', required: true },
{ name: 'status', required: true },
{ name: 'message', required: false }
]);
});
it('should handle re-exported enums', () => {
const source = `
export { OrderStatus } from './types';
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
// Re-exported enums should be in namedExports
expect(result.namedExports.has('OrderStatus')).toBe(true);
// Without the source file, we can't get the enum values
expect(result.typeDefinitions?.has('OrderStatus')).toBe(false);
});
it('should parse re-exported enums when git branch info is provided', () => {
// Mock execSync to return the types.ts content
mockExecSync.mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd.includes('types.ts')) {
return `
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
SHIPPED = 'shipped'
}`;
}
return '';
});
const source = `
export { OrderStatus } from './types.js';
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0', undefined, 'main', '/path/to/package');
// Should have the enum definition with members
expect(result.namedExports.has('OrderStatus')).toBe(true);
expect(result.typeDefinitions?.has('OrderStatus')).toBe(true);
const enumDef = result.typeDefinitions?.get('OrderStatus');
expect(enumDef?.kind).toBe('enum');
expect(enumDef?.members).toEqual(['PENDING', 'CONFIRMED', 'SHIPPED']);
mockExecSync.mockClear();
});
it('should parse re-exported interfaces when git branch info is provided', () => {
// Mock execSync to return the types.ts content
mockExecSync.mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd.includes('types.ts')) {
return `
export interface User {
id: string;
name: string;
email?: string;
}`;
}
return '';
});
const source = `
export type { User } from './types.js';
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0', undefined, 'main', '/path/to/package');
// Should have the interface definition with properties
expect(result.typeOnlyExports.has('User')).toBe(true);
expect(result.typeDefinitions?.has('User')).toBe(true);
const interfaceDef = result.typeDefinitions?.get('User');
expect(interfaceDef?.kind).toBe('interface');
expect(interfaceDef?.extendedProperties).toEqual([
{ name: 'id', required: true },
{ name: 'name', required: true },
{ name: 'email', required: false }
]);
mockExecSync.mockClear();
});
it('should parse arrow function signatures', () => {
const source = `
export const simpleFunc = (name: string) => \`Hello \${name}\`;
export const complexFunc = (name: string, age: number): string => \`\${name} is \${age}\`;
export const asyncFunc = async (id: string): Promise<User> => fetchUser(id);
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.namedExports.has('simpleFunc')).toBe(true);
expect(result.namedExports.has('complexFunc')).toBe(true);
expect(result.namedExports.has('asyncFunc')).toBe(true);
// Check function signatures are captured
const simpleFuncDef = result.typeDefinitions?.get('simpleFunc');
expect(simpleFuncDef).toBeDefined();
expect(simpleFuncDef?.kind).toBe('function');
expect(simpleFuncDef?.signature).toContain('(name: string)');
const complexFuncDef = result.typeDefinitions?.get('complexFunc');
expect(complexFuncDef).toBeDefined();
expect(complexFuncDef?.signature).toContain('(name: string, age: number)');
expect(complexFuncDef?.signature).toContain(': string');
const asyncFuncDef = result.typeDefinitions?.get('asyncFunc');
expect(asyncFuncDef).toBeDefined();
expect(asyncFuncDef?.signature).toContain('async');
expect(asyncFuncDef?.signature).toContain('(id: string)');
expect(asyncFuncDef?.signature).toContain(': Promise<User>');
});
it('should parse function type aliases', () => {
const source = `
export type EventHandler<T = any> = (event: T) => void;
export type AsyncHandler<T> = (event: T, context?: any) => Promise<void>;
export type Callback = () => void;
`;
const result = parseApiSurface(source, 'test-pkg', '1.0.0');
expect(result.typeOnlyExports.has('EventHandler')).toBe(true);
expect(result.typeOnlyExports.has('AsyncHandler')).toBe(true);
expect(result.typeOnlyExports.has('Callback')).toBe(true);
// Check function type signatures are captured
const eventHandlerDef = result.typeDefinitions?.get('EventHandler');
expect(eventHandlerDef).toBeDefined();
expect(eventHandlerDef?.kind).toBe('type');
expect(eventHandlerDef?.signature).toContain('(event: T)');
expect(eventHandlerDef?.signature).toContain('=> void');
const asyncHandlerDef = result.typeDefinitions?.get('AsyncHandler');
expect(asyncHandlerDef).toBeDefined();
expect(asyncHandlerDef?.signature).toContain('(event: T, context?: any)');
expect(asyncHandlerDef?.signature).toContain('=> Promise<void>');
});
});
});