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