UNPKG

apisurf

Version:

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

293 lines (286 loc) 12.8 kB
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Create mock const mockFs = { existsSync: jest.fn(), readFileSync: jest.fn() }; // Mock fs module jest.unstable_mockModule('fs', () => mockFs); // Import after mocking const { parseTypeScriptDefinitions } = await import('./parseTypeScriptDefinitions.js'); describe('parseTypeScriptDefinitions', () => { beforeEach(() => { jest.clearAllMocks(); mockFs.existsSync.mockReturnValue(false); }); it('should parse basic interface exports', () => { const sourceContent = ` export interface TestInterface { name: string; value: number; } `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.typeOnlyExports.has('TestInterface')).toBe(true); expect(result.namedExports.has('TestInterface')).toBe(false); expect(result.typeDefinitions?.has('TestInterface')).toBe(true); const typeDef = result.typeDefinitions?.get('TestInterface'); expect(typeDef?.kind).toBe('interface'); expect(typeDef?.properties?.has('name')).toBe(true); expect(typeDef?.properties?.get('name')).toBe('string'); expect(typeDef?.properties?.has('value')).toBe(true); expect(typeDef?.properties?.get('value')).toBe('number'); }); it('should parse type alias exports', () => { const sourceContent = ` export type StringOrNumber = string | number; export type Config = { enabled: boolean; timeout?: number; }; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.typeOnlyExports.has('StringOrNumber')).toBe(true); expect(result.typeOnlyExports.has('Config')).toBe(true); expect(result.typeDefinitions?.has('StringOrNumber')).toBe(true); expect(result.typeDefinitions?.has('Config')).toBe(true); const stringOrNumberDef = result.typeDefinitions?.get('StringOrNumber'); expect(stringOrNumberDef?.kind).toBe('type'); }); it('should parse function exports', () => { const sourceContent = ` export function greet(name: string): string; export function calculate(a: number, b: number): number; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('greet')).toBe(true); expect(result.namedExports.has('calculate')).toBe(true); expect(result.typeDefinitions?.has('greet')).toBe(true); expect(result.typeDefinitions?.has('calculate')).toBe(true); const greetDef = result.typeDefinitions?.get('greet'); expect(greetDef?.kind).toBe('function'); expect(greetDef?.parameters).toEqual(['name: string']); expect(greetDef?.returnType).toBe('string'); const calculateDef = result.typeDefinitions?.get('calculate'); expect(calculateDef?.parameters).toEqual(['a: number', 'b: number']); expect(calculateDef?.returnType).toBe('number'); }); it('should parse variable exports', () => { const sourceContent = ` export const API_VERSION: string; export let isEnabled: boolean; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('API_VERSION')).toBe(true); expect(result.namedExports.has('isEnabled')).toBe(true); expect(result.typeDefinitions?.has('API_VERSION')).toBe(true); expect(result.typeDefinitions?.has('isEnabled')).toBe(true); const apiVersionDef = result.typeDefinitions?.get('API_VERSION'); expect(apiVersionDef?.kind).toBe('variable'); expect(apiVersionDef?.signature).toContain('API_VERSION: string'); }); it('should parse class exports', () => { const sourceContent = ` export class TestClass { public name: string; private value: number; constructor(name: string); getName(): string; setValue(value: number): void; } `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('TestClass')).toBe(true); expect(result.typeDefinitions?.has('TestClass')).toBe(true); const classDef = result.typeDefinitions?.get('TestClass'); expect(classDef?.kind).toBe('class'); expect(classDef?.properties?.has('name')).toBe(true); expect(classDef?.properties?.has('value')).toBe(true); expect(classDef?.properties?.has('getName')).toBe(true); expect(classDef?.properties?.has('setValue')).toBe(true); }); it('should parse enum exports', () => { const sourceContent = ` export enum Color { Red = "red", Green = "green", Blue = "blue" } `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('Color')).toBe(true); expect(result.typeDefinitions?.has('Color')).toBe(true); const enumDef = result.typeDefinitions?.get('Color'); expect(enumDef?.kind).toBe('enum'); }); it('should parse named export declarations', () => { const sourceContent = ` interface InternalInterface { id: number; } function internalFunction(): void {} export { InternalInterface, internalFunction }; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); // Named exports from export {...} statements are treated as named exports // regardless of whether they are types or values in the current implementation expect(result.namedExports.has('InternalInterface')).toBe(true); expect(result.namedExports.has('internalFunction')).toBe(true); }); it('should parse type-only named exports', () => { const sourceContent = ` interface InternalInterface { id: number; } function internalFunction(): void {} export type { InternalInterface }; export { internalFunction }; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.typeOnlyExports.has('InternalInterface')).toBe(true); expect(result.namedExports.has('InternalInterface')).toBe(false); expect(result.namedExports.has('internalFunction')).toBe(true); }); it('should handle star exports', () => { const sourceContent = ` export * from './internal-module'; export * from 'external-package'; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.starExports).toContain('./internal-module'); expect(result.starExports).toContain('external-package'); }); it('should detect default export assignments', () => { const sourceContent = ` class InternalClass {} export = InternalClass; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.defaultExport).toBe(true); }); it('should expand internal star exports when file exists', () => { const sourceContent = ` export * from './internal-module'; `; const internalModuleContent = ` export interface InternalInterface { value: string; } export function internalFunction(): void; `; mockFs.existsSync.mockImplementation((filePath) => { return filePath.includes('internal-module.d.ts'); }); mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('internal-module.d.ts')) { return internalModuleContent; } return ''; }); const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0', '/test/base.d.ts'); expect(result.typeOnlyExports.has('InternalInterface')).toBe(true); expect(result.namedExports.has('internalFunction')).toBe(true); }); it('should handle star exports gracefully when internal module cannot be found', () => { const sourceContent = ` export * from './non-existent-module'; `; mockFs.existsSync.mockReturnValue(false); const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0', '/test/base.d.ts'); expect(result.starExports).toContain('./non-existent-module'); }); it('should handle interface methods correctly', () => { const sourceContent = ` export interface ServiceInterface { readonly id: string; getName(): string; setName(name: string): void; process(data: any): Promise<boolean>; } `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.typeOnlyExports.has('ServiceInterface')).toBe(true); const interfaceDef = result.typeDefinitions?.get('ServiceInterface'); expect(interfaceDef?.properties?.has('id')).toBe(true); expect(interfaceDef?.properties?.has('getName')).toBe(true); expect(interfaceDef?.properties?.has('setName')).toBe(true); expect(interfaceDef?.properties?.has('process')).toBe(true); }); it('should handle class methods correctly', () => { const sourceContent = ` export class MyClass { private _value: number; constructor(value: number); getValue(): number; setValue(value: number): void; static create(value: number): MyClass; } `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('MyClass')).toBe(true); const classDef = result.typeDefinitions?.get('MyClass'); expect(classDef?.properties?.has('_value')).toBe(true); expect(classDef?.properties?.has('getValue')).toBe(true); expect(classDef?.properties?.has('setValue')).toBe(true); expect(classDef?.properties?.has('create')).toBe(true); }); it('should handle complex function signatures', () => { const sourceContent = ` export function complexFunction<T>( items: T[], predicate: (item: T) => boolean, options?: { limit?: number } ): T[]; `; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('complexFunction')).toBe(true); const funcDef = result.typeDefinitions?.get('complexFunction'); expect(funcDef?.kind).toBe('function'); expect(funcDef?.parameters).toBeDefined(); expect(funcDef?.parameters?.length).toBe(3); }); it('should return correct package metadata', () => { const sourceContent = `export interface Test {}`; const result = parseTypeScriptDefinitions(sourceContent, 'my-package', '2.1.0'); expect(result.packageName).toBe('my-package'); expect(result.version).toBe('2.1.0'); }); it('should handle empty or minimal TypeScript files', () => { const sourceContent = `// This is an empty definition file`; const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.size).toBe(0); expect(result.typeOnlyExports.size).toBe(0); expect(result.starExports.length).toBe(0); expect(result.defaultExport).toBe(false); expect(result.typeDefinitions?.size).toBe(0); }); it('should handle nested internal star export expansion', () => { const sourceContent = ` export * from './level1'; `; const level1Content = ` export * from './level2'; export interface Level1Interface { level: number; } `; const level2Content = ` export function level2Function(): string; `; mockFs.existsSync.mockImplementation((filePath) => { return filePath.includes('level1.d.ts') || filePath.includes('level2.d.ts'); }); mockFs.readFileSync.mockImplementation((filePath) => { if (filePath.includes('level1.d.ts')) { return level1Content; } if (filePath.includes('level2.d.ts')) { return level2Content; } return ''; }); const result = parseTypeScriptDefinitions(sourceContent, 'test-package', '1.0.0', '/test/base.d.ts'); expect(result.typeOnlyExports.has('Level1Interface')).toBe(true); expect(result.namedExports.has('level2Function')).toBe(true); }); });