UNPKG

apisurf

Version:

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

240 lines (239 loc) 10.5 kB
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>'); }); }); });