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