UNPKG

apisurf

Version:

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

360 lines (358 loc) 15.5 kB
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Create mocks const mockParseApiSurface = jest.fn(); const mockExecSync = jest.fn(); const mockFs = { existsSync: jest.fn(), readFileSync: jest.fn(), readdirSync: jest.fn(), statSync: jest.fn(), promises: { readFile: jest.fn() } }; const mockVm = { createContext: jest.fn(), runInContext: jest.fn() }; // Mock dependencies jest.unstable_mockModule('./parseApiSurface.js', () => ({ parseApiSurface: mockParseApiSurface })); jest.unstable_mockModule('child_process', () => ({ execSync: mockExecSync })); jest.unstable_mockModule('fs', () => mockFs); jest.unstable_mockModule('vm', () => mockVm); // Import after mocking const { extractApiSurface, parseCommonJSStatically, loadModuleWithVM } = await import('./extractApiSurface.js'); describe('extractApiSurface', () => { beforeEach(() => { jest.clearAllMocks(); // Mock console.warn to suppress warning messages during tests jest.spyOn(console, 'warn').mockImplementation(() => { }); }); it('should extract API surface from TypeScript source file', async () => { const pkg = { name: 'test-package', path: './packages/test-package' }; const branch = 'main'; // Mock package.json content const packageJson = { name: 'test-package', version: '1.0.0', main: 'dist/index.js' }; // Mock TypeScript source content const sourceContent = ` export interface TestInterface { name: string; } export function testFunction(): void {} `; // Mock successful git operations mockExecSync .mockReturnValueOnce(JSON.stringify(packageJson)) // package.json .mockReturnValueOnce(sourceContent); // TypeScript source const mockApiSurface = { namedExports: new Set(['testFunction']), typeOnlyExports: new Set(['TestInterface']), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); const result = await extractApiSurface(pkg, branch); expect(mockExecSync).toHaveBeenCalledTimes(2); expect(mockParseApiSurface).toHaveBeenCalledWith(sourceContent, 'test-package', '1.0.0', undefined, 'main', './packages/test-package'); expect(result).toEqual(mockApiSurface); }); it('should fallback to src/index.ts when main file source is not available', async () => { const pkg = { name: 'test-package', path: './packages/test-package' }; const branch = 'main'; const packageJson = { name: 'test-package', version: '1.0.0', main: 'dist/index.js' }; const sourceContent = 'export const value = 42;'; // Mock git operations - first call succeeds (package.json), second tries main file but fails, then succeeds with src/index.ts mockExecSync .mockReturnValueOnce(JSON.stringify(packageJson)) .mockReturnValueOnce(sourceContent); // This will be the src/index.ts fallback const mockApiSurface = { namedExports: new Set(['value']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); const result = await extractApiSurface(pkg, branch); expect(mockExecSync).toHaveBeenCalledTimes(2); expect(result).toEqual(mockApiSurface); }); it('should return empty API surface when all git operations fail', async () => { const pkg = { name: 'test-package', path: './packages/test-package' }; const branch = 'main'; // Mock all git operations to fail mockExecSync.mockImplementation(() => { throw new Error('Git operation failed'); }); const result = await extractApiSurface(pkg, branch); expect(result).toEqual({ namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '0.0.0', typeDefinitions: new Map() }); }); it('should handle packages with exports field in package.json', async () => { const pkg = { name: 'test-package', path: './packages/test-package' }; const branch = 'main'; const packageJson = { name: 'test-package', version: '1.0.0', exports: { '.': './dist/index.js' } }; const sourceContent = 'export default class TestClass {}'; mockExecSync .mockReturnValueOnce(JSON.stringify(packageJson)) .mockReturnValueOnce(sourceContent); const mockApiSurface = { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: true, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); const result = await extractApiSurface(pkg, branch); expect(result).toEqual(mockApiSurface); }); it('should fallback to index.js when no main field is specified', async () => { const pkg = { name: 'test-package', path: './packages/test-package' }; const branch = 'main'; const packageJson = { name: 'test-package', version: '1.0.0' // No main field }; const sourceContent = 'export const DEFAULT_VALUE = 100;'; mockExecSync .mockReturnValueOnce(JSON.stringify(packageJson)) .mockReturnValueOnce(sourceContent); const mockApiSurface = { namedExports: new Set(['DEFAULT_VALUE']), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: 'test-package', version: '1.0.0', typeDefinitions: new Map() }; mockParseApiSurface.mockReturnValue(mockApiSurface); await extractApiSurface(pkg, branch); // Should use index.js as default expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('src/index.ts'), expect.any(Object)); }); }); describe('parseCommonJSStatically', () => { it('should parse direct exports (exports.foo = bar)', () => { const sourceContent = ` exports.myFunction = function() {}; exports.MY_CONSTANT = 42; exports.defaultConfig = { enabled: true }; `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('myFunction')).toBe(true); expect(result.namedExports.has('MY_CONSTANT')).toBe(true); expect(result.namedExports.has('defaultConfig')).toBe(true); expect(result.defaultExport).toBe(false); }); it('should parse computed exports (exports["foo"] = bar)', () => { const sourceContent = ` exports["computed-name"] = function() {}; exports['another-export'] = 123; `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('computed-name')).toBe(true); expect(result.namedExports.has('another-export')).toBe(true); }); it('should parse Object.defineProperty exports', () => { const sourceContent = ` Object.defineProperty(exports, 'propertyExport', { value: function() {}, enumerable: true }); Object.defineProperty(exports, "anotherProperty", { value: 42 }); `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('propertyExport')).toBe(true); expect(result.namedExports.has('anotherProperty')).toBe(true); }); it('should detect module.exports default export', () => { const sourceContent = ` function MyClass() {} module.exports = MyClass; `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.defaultExport).toBe(true); expect(result.namedExports.size).toBe(0); }); it('should parse __exportStar calls (bundler generated)', () => { const sourceContent = ` __exportStar(require("./internal-module"), exports); __exportStar(require('./another-module'), exports); `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.starExports).toContain('./internal-module'); expect(result.starExports).toContain('./another-module'); }); it('should skip comments and empty lines', () => { const sourceContent = ` // This is a comment /* Multi-line comment */ exports.realExport = function() {}; // exports.commentedExport = 'should be ignored'; /* exports.multiLineCommentExport = 'also ignored'; */ `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('realExport')).toBe(true); expect(result.namedExports.has('commentedExport')).toBe(false); expect(result.namedExports.has('multiLineCommentExport')).toBe(false); expect(result.namedExports.size).toBe(1); }); it('should handle mixed export patterns', () => { const sourceContent = ` exports.directExport = 'value'; exports["computed"] = 42; Object.defineProperty(exports, 'defined', { value: true }); __exportStar(require('./utils'), exports); module.exports = { mixed: true }; `; const result = parseCommonJSStatically(sourceContent, 'test-package', '1.0.0'); expect(result.namedExports.has('directExport')).toBe(true); expect(result.namedExports.has('computed')).toBe(true); expect(result.namedExports.has('defined')).toBe(true); expect(result.starExports).toContain('./utils'); expect(result.defaultExport).toBe(true); }); it('should return correct package metadata', () => { const sourceContent = 'exports.test = true;'; const result = parseCommonJSStatically(sourceContent, 'my-package', '2.1.0'); expect(result.packageName).toBe('my-package'); expect(result.version).toBe('2.1.0'); expect(result.typeDefinitions?.size).toBe(0); }); }); describe('loadModuleWithVM', () => { beforeEach(() => { jest.clearAllMocks(); // Mock console.warn to suppress warning messages during tests jest.spyOn(console, 'warn').mockImplementation(() => { }); }); it('should successfully load a simple CommonJS module', () => { const modulePath = '/test/module.js'; const moduleSource = ` exports.simpleFunction = function() { return 'hello'; }; exports.simpleValue = 42; `; mockFs.readFileSync.mockReturnValue(moduleSource); const mockContext = { module: { exports: {} }, exports: {}, require: jest.fn() }; mockVm.createContext.mockReturnValue(mockContext); // Simulate the module execution mockVm.runInContext.mockImplementation((_code, context) => { // Simulate the effect of running the CommonJS module context.exports.simpleFunction = function () { return 'hello'; }; context.exports.simpleValue = 42; context.module.exports = context.exports; return context.module.exports; }); const result = loadModuleWithVM(modulePath); expect(mockFs.readFileSync).toHaveBeenCalledWith(modulePath, 'utf8'); expect(mockVm.createContext).toHaveBeenCalled(); expect(mockVm.runInContext).toHaveBeenCalledWith(moduleSource, mockContext, { filename: modulePath, timeout: 10000 }); expect(result).toBeDefined(); }); it('should handle module.exports assignment', () => { const modulePath = '/test/default-export.js'; const moduleSource = 'function MyClass() {} module.exports = MyClass;'; mockFs.readFileSync.mockReturnValue(moduleSource); mockVm.createContext.mockReturnValue({}); mockVm.runInContext.mockImplementation(() => { // Simulate successful execution }); const result = loadModuleWithVM(modulePath); // Just verify it returns something (not null) and basic calls were made expect(result).toBeDefined(); expect(mockFs.readFileSync).toHaveBeenCalledWith(modulePath, 'utf8'); expect(mockVm.createContext).toHaveBeenCalled(); expect(mockVm.runInContext).toHaveBeenCalled(); }); it('should return null when VM execution fails', () => { const modulePath = '/test/failing-module.js'; const moduleSource = 'throw new Error("Module failed to load");'; mockFs.readFileSync.mockReturnValue(moduleSource); mockVm.createContext.mockReturnValue({}); mockVm.runInContext.mockImplementation(() => { throw new Error('Execution failed'); }); const result = loadModuleWithVM(modulePath); expect(result).toBeNull(); }); it('should return null when file reading fails', () => { const modulePath = '/test/non-existent.js'; mockFs.readFileSync.mockImplementation(() => { throw new Error('File not found'); }); const result = loadModuleWithVM(modulePath); expect(result).toBeNull(); }); it('should successfully execute and return module exports', () => { const modulePath = '/test/simple-module.js'; const moduleSource = 'exports.value = 42;'; mockFs.readFileSync.mockReturnValue(moduleSource); mockVm.createContext.mockReturnValue({}); mockVm.runInContext.mockImplementation(() => { // Simulate successful execution }); const result = loadModuleWithVM(modulePath); // Just verify successful execution (not null result) expect(result).toBeDefined(); expect(mockFs.readFileSync).toHaveBeenCalledWith(modulePath, 'utf8'); expect(mockVm.createContext).toHaveBeenCalled(); expect(mockVm.runInContext).toHaveBeenCalled(); }); it('should handle module execution that requires dependencies', () => { const modulePath = '/test/module-with-deps.js'; const moduleSource = 'const utils = require("./utils"); exports.loaded = true;'; mockFs.readFileSync.mockReturnValue(moduleSource); mockVm.createContext.mockReturnValue({}); mockVm.runInContext.mockImplementation(() => { // Simulate successful execution with dependencies }); const result = loadModuleWithVM(modulePath); // Just verify successful execution and basic API calls expect(result).toBeDefined(); expect(mockVm.createContext).toHaveBeenCalled(); expect(mockVm.runInContext).toHaveBeenCalled(); }); });