ruch
Version:
Revolutionary React TypeScript CLI with hexagonal architecture & AI-powered development assistance. Create maintainable, scalable applications with domain-driven design and integrated AI tooling.
447 lines (394 loc) • 14.2 kB
text/typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
import {
extractImportStatements,
extractDomainFromImportPath,
scanDomainFiles,
analyzeDomainDependencies,
detectCircularDependencies,
generateGraphData,
type DomainNode
} from '../src/utils/dependency-analyzer';
import type { FileSystem } from '../src/utils/file-operations';
// Mock FileSystem for tests
const createMockFileSystem = (files: Record<string, string> = {}, directories: string[] = []): FileSystem => ({
writeFile: vi.fn() as any,
readFile: vi.fn().mockImplementation(async (filePath: string) => {
if (files[filePath]) {
return files[filePath];
}
throw new Error(`File not found: ${filePath}`);
}) as any,
readdir: vi.fn().mockImplementation(async (dirPath: string) => {
const dirEntries = Object.keys(files)
.filter(file => path.dirname(file) === dirPath)
.map(file => path.basename(file));
const subDirs = directories
.filter(dir => path.dirname(dir) === dirPath && dir !== dirPath)
.map(dir => path.basename(dir));
return [...new Set([...dirEntries, ...subDirs])];
}) as any,
stat: vi.fn().mockImplementation(async (filePath: string) => ({
isDirectory: () => directories.includes(filePath),
isFile: () => files[filePath] !== undefined
})) as any,
remove: vi.fn() as any,
mkdir: vi.fn() as any,
exists: vi.fn().mockImplementation(async (filePath: string) => {
return files[filePath] !== undefined || directories.includes(filePath);
}),
pathExists: vi.fn(),
ensureDir: vi.fn()
});
describe('Dependency Analyzer', () => {
describe('extractImportStatements', () => {
it('should extract import statements', () => {
const content = `
import React from 'react';
import { useState, useEffect } from 'react';
import type { User } from '../types';
import * as utils from '../utils';
import { ApiService } from '../../api/ApiService';
export { UserService } from '../services/UserService';
export * from './types';
const someCode = "import fake from 'fake'";
`;
const imports = extractImportStatements(content);
expect(imports).toHaveLength(7);
expect(imports[0]).toEqual({
statement: `import React from 'react';`,
line: 2,
from: 'react',
type: 'import'
});
expect(imports[1]).toEqual({
statement: `import { useState, useEffect } from 'react';`,
line: 3,
from: 'react',
type: 'import'
});
expect(imports[2]).toEqual({
statement: `import type { User } from '../types';`,
line: 4,
from: '../types',
type: 'import'
});
expect(imports[3]).toEqual({
statement: `import * as utils from '../utils';`,
line: 5,
from: '../utils',
type: 'import'
});
expect(imports[4]).toEqual({
statement: `import { ApiService } from '../../api/ApiService';`,
line: 6,
from: '../../api/ApiService',
type: 'import'
});
expect(imports[5]).toEqual({
statement: `export { UserService } from '../services/UserService';`,
line: 7,
from: '../services/UserService',
type: 'export'
});
});
it('should handle empty content', () => {
const imports = extractImportStatements('');
expect(imports).toHaveLength(0);
});
it('should handle content without imports', () => {
const content = `
const x = 1;
function test() {
return "hello";
}
`;
const imports = extractImportStatements(content);
expect(imports).toHaveLength(0);
});
});
describe('extractDomainFromImportPath', () => {
it('should extract domain from cross-domain import', () => {
const currentDomainPath = '/project/src/domains/user/services/UserService.ts';
const importPath = '../../product/entities/Product';
const domain = extractDomainFromImportPath(importPath, currentDomainPath);
expect(domain).toBe('product');
});
it('should return null for same-domain import', () => {
const currentDomainPath = '/project/src/domains/user/services/UserService.ts';
const importPath = '../entities/User';
const domain = extractDomainFromImportPath(importPath, currentDomainPath);
expect(domain).toBeNull();
});
it('should return null for external imports', () => {
const currentDomainPath = '/project/src/domains/user/services/UserService.ts';
const importPath = 'react';
const domain = extractDomainFromImportPath(importPath, currentDomainPath);
expect(domain).toBeNull();
});
it('should return null for non-domain imports', () => {
const currentDomainPath = '/project/src/domains/user/services/UserService.ts';
const importPath = '../../../utils/helpers';
const domain = extractDomainFromImportPath(importPath, currentDomainPath);
expect(domain).toBeNull();
});
});
describe('scanDomainFiles', () => {
it('should scan and return TypeScript files in a domain', async () => {
const files = {
'/project/src/domains/user/entities/User.ts': 'content',
'/project/src/domains/user/services/UserService.ts': 'content',
'/project/src/domains/user/adapters/UserAdapter.tsx': 'content',
'/project/src/domains/user/README.md': 'content', // Should be ignored
'/project/src/domains/user/package.json': 'content' // Should be ignored
};
const directories = [
'/project/src/domains/user',
'/project/src/domains/user/entities',
'/project/src/domains/user/services',
'/project/src/domains/user/adapters'
];
const fileSystem = createMockFileSystem(files, directories);
const result = await scanDomainFiles(fileSystem, '/project/src/domains/user');
expect(result).toHaveLength(3);
expect(result).toContain('/project/src/domains/user/entities/User.ts');
expect(result).toContain('/project/src/domains/user/services/UserService.ts');
expect(result).toContain('/project/src/domains/user/adapters/UserAdapter.tsx');
});
it('should handle empty domain directory', async () => {
const fileSystem = createMockFileSystem({}, ['/project/src/domains/empty']);
const result = await scanDomainFiles(fileSystem, '/project/src/domains/empty');
expect(result).toHaveLength(0);
});
it('should handle non-existent domain directory', async () => {
const fileSystem = createMockFileSystem();
const result = await scanDomainFiles(fileSystem, '/project/src/domains/nonexistent');
expect(result).toHaveLength(0);
});
});
describe('analyzeDomainDependencies', () => {
it('should analyze dependencies correctly', async () => {
const files = {
'/project/src/domains/user/services/UserService.ts': `
import { Product } from '../../product/entities/Product';
import { ApiService } from '../../shared/api/ApiService';
import { UserEntity } from '../entities/User';
`,
'/project/src/domains/user/entities/User.ts': `
import { Order } from '../../order/entities/Order';
`
};
const directories = [
'/project/src/domains/user',
'/project/src/domains/user/services',
'/project/src/domains/user/entities'
];
const fileSystem = createMockFileSystem(files, directories);
const result = await analyzeDomainDependencies(
fileSystem,
'/project/src/domains/user',
'user'
);
expect(result.dependencies).toHaveLength(3);
expect(result.dependencies).toContain('product');
expect(result.dependencies).toContain('shared');
expect(result.dependencies).toContain('order');
expect(result.files).toHaveLength(2);
});
it('should handle domain with no dependencies', async () => {
const files = {
'/project/src/domains/isolated/entities/Entity.ts': `
export interface Entity {
id: string;
}
`
};
const directories = [
'/project/src/domains/isolated',
'/project/src/domains/isolated/entities'
];
const fileSystem = createMockFileSystem(files, directories);
const result = await analyzeDomainDependencies(
fileSystem,
'/project/src/domains/isolated',
'isolated'
);
expect(result.dependencies).toHaveLength(0);
expect(result.files).toHaveLength(1);
});
});
describe('detectCircularDependencies', () => {
it('should detect simple circular dependency', () => {
const nodes: DomainNode[] = [
{
id: 'user',
name: 'user',
type: 'feature',
fileCount: 3,
dependencies: ['product'],
dependents: ['product'],
files: []
},
{
id: 'product',
name: 'product',
type: 'feature',
fileCount: 2,
dependencies: ['user'],
dependents: ['user'],
files: []
}
];
const cycles = detectCircularDependencies(nodes);
expect(cycles).toHaveLength(1);
expect(cycles[0]).toEqual(['user', 'product']);
});
it('should detect complex circular dependency', () => {
const nodes: DomainNode[] = [
{
id: 'user',
name: 'user',
type: 'feature',
fileCount: 3,
dependencies: ['product'],
dependents: ['order'],
files: []
},
{
id: 'product',
name: 'product',
type: 'feature',
fileCount: 2,
dependencies: ['order'],
dependents: ['user'],
files: []
},
{
id: 'order',
name: 'order',
type: 'feature',
fileCount: 4,
dependencies: ['user'],
dependents: ['product'],
files: []
}
];
const cycles = detectCircularDependencies(nodes);
expect(cycles).toHaveLength(1);
expect(cycles[0]).toEqual(['user', 'product', 'order']);
});
it('should return empty array when no cycles exist', () => {
const nodes: DomainNode[] = [
{
id: 'user',
name: 'user',
type: 'feature',
fileCount: 3,
dependencies: ['shared'],
dependents: [],
files: []
},
{
id: 'product',
name: 'product',
type: 'feature',
fileCount: 2,
dependencies: ['shared'],
dependents: [],
files: []
},
{
id: 'shared',
name: 'shared',
type: 'shared',
fileCount: 1,
dependencies: [],
dependents: ['user', 'product'],
files: []
}
];
const cycles = detectCircularDependencies(nodes);
expect(cycles).toHaveLength(0);
});
});
describe('generateGraphData', () => {
it('should generate complete graph data', async () => {
const files = {
'/project/src/domains/user/entities/User.ts': `
import { Product } from '../../product/entities/Product';
`,
'/project/src/domains/product/entities/Product.ts': `
export interface Product {
id: string;
}
`,
'/project/src/domains/shared/utils/helpers.ts': `
export const helper = () => {};
`
};
const directories = [
'/project/src',
'/project/src/domains',
'/project/src/domains/user',
'/project/src/domains/user/entities',
'/project/src/domains/product',
'/project/src/domains/product/entities',
'/project/src/domains/shared',
'/project/src/domains/shared/utils'
];
const fileSystem = createMockFileSystem(files, directories);
const result = await generateGraphData('/project', fileSystem);
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(1);
expect(result.stats.totalDomains).toBe(3);
expect(result.stats.totalDependencies).toBe(1);
expect(result.stats.circularDependencyCount).toBe(0);
const userNode = result.nodes.find(n => n.name === 'user');
expect(userNode).toBeDefined();
expect(userNode?.dependencies).toContain('product');
expect(userNode?.fileCount).toBe(1);
const productNode = result.nodes.find(n => n.name === 'product');
expect(productNode).toBeDefined();
expect(productNode?.dependents).toContain('user');
const edge = result.edges[0];
expect(edge).toBeDefined();
if (edge) {
expect(edge.from).toBe('user');
expect(edge.to).toBe('product');
expect(edge.type).toBe('direct-import');
}
});
it('should handle project with no domains', async () => {
const directories = ['/project/src'];
const fileSystem = createMockFileSystem({}, directories);
const result = await generateGraphData('/project', fileSystem);
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
expect(result.stats.totalDomains).toBe(0);
expect(result.stats.totalDependencies).toBe(0);
expect(result.stats.circularDependencyCount).toBe(0);
});
it('should detect and count circular dependencies', async () => {
const files = {
'/project/src/domains/user/entities/User.ts': `
import { Product } from '../../product/entities/Product';
`,
'/project/src/domains/product/entities/Product.ts': `
import { User } from '../../user/entities/User';
`
};
const directories = [
'/project/src',
'/project/src/domains',
'/project/src/domains/user',
'/project/src/domains/user/entities',
'/project/src/domains/product',
'/project/src/domains/product/entities'
];
const fileSystem = createMockFileSystem(files, directories);
const result = await generateGraphData('/project', fileSystem);
expect(result.stats.circularDependencyCount).toBe(1);
expect(result.circularDependencies).toHaveLength(1);
});
});
});