diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8b4e1a8..36aab08 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,7 +67,7 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", "workspaceFolder": "/workspace", - "postCreateCommand": "yarn install", + "postCreateCommand": "yarn install && mkdir -p /home/node/.m2 && cp /workspace/.devcontainer/maven-settings.xml /home/node/.m2/settings.xml", "postStartCommand": "mkdir -p /home/node/.m2/repository", "features": { "ghcr.io/devcontainers/features/git:1": { diff --git a/.devcontainer/maven-settings.xml b/.devcontainer/maven-settings.xml new file mode 100644 index 0000000..c76561a --- /dev/null +++ b/.devcontainer/maven-settings.xml @@ -0,0 +1,68 @@ + + + + + + + maven-default-http-blocker + external:http:*,!folio-nexus,!folio-snapshots,!apache.snapshots,!index-data-nexus + Pseudo repository to mirror external repositories initially using HTTP. + http://0.0.0.0/ + true + + + + + + + folio + + + folio-nexus + FOLIO Maven Repository + https://repository.folio.org/repository/maven-folio/ + + true + + + true + + + + folio-snapshots + FOLIO Snapshot Repository + https://repository.folio.org/repository/snapshots/ + + false + + + true + always + + + + + + folio-nexus + FOLIO Maven Repository + https://repository.folio.org/repository/maven-folio/ + + true + + + true + + + + + + + + + folio + + + diff --git a/package.json b/package.json index cc94571..db40945 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "start": "node dist/cli.js", "dev": "ts-node src/cli.ts", "test": "jest", + "test:unit": "jest --testPathIgnorePatterns='integration'", + "test:integration": "jest --testMatch='**/*.integration.test.ts' --runInBand", "clean": "rm -rf dist" }, "keywords": [ diff --git a/src/__tests__/integration/cli.integration.test.ts b/src/__tests__/integration/cli.integration.test.ts new file mode 100644 index 0000000..148e40c --- /dev/null +++ b/src/__tests__/integration/cli.integration.test.ts @@ -0,0 +1,296 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import { ModuleEvaluator } from '../../module-evaluator'; +import { ReportGenerator } from '../../utils/report-generator'; +import { EvaluationConfig, ReportOptions } from '../../types'; + +describe('CLI Integration Tests', () => { + const testOutputDir = path.join(os.tmpdir(), 'folio-eval-integration-tests', Date.now().toString()); + + beforeAll(async () => { + // Create test output directory + await fs.ensureDir(testOutputDir); + }); + + afterAll(async () => { + // Clean up test output directory + try { + await fs.remove(testOutputDir); + } catch (error) { + console.warn('Failed to clean up test output directory:', error); + } + }); + + // Extended timeout for integration tests (5 minutes per test) + jest.setTimeout(300000); + + describe('Repository Evaluation', () => { + test('should evaluate mod-search (Java backend module)', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + + // Verify evaluation result structure + expect(result).toBeDefined(); + expect(result.moduleName).toBe('mod-search'); + expect(result.repositoryUrl).toBe(repoUrl); + expect(result.evaluatedAt).toBeDefined(); + expect(result.language).toBe('Java'); + expect(result.criteria).toBeInstanceOf(Array); + expect(result.criteria.length).toBeGreaterThan(0); + + // Verify each result has required fields + result.criteria.forEach((criterionResult) => { + expect(criterionResult.criterionId).toBeDefined(); + expect(criterionResult.status).toMatch(/pass|fail|manual|not_applicable/); + expect(criterionResult.evidence).toBeDefined(); + }); + + // Generate reports + const reportGenerator = new ReportGenerator(testOutputDir); + const reportPaths = await reportGenerator.generateReports(result); + + // Verify reports were generated + expect(reportPaths.htmlPath).toBeDefined(); + expect(reportPaths.jsonPath).toBeDefined(); + + // Verify HTML report exists and has content + const htmlContent = await fs.readFile(reportPaths.htmlPath!, 'utf-8'); + expect(htmlContent).toContain('mod-search'); + expect(htmlContent).toContain('Evaluation Report'); + + // Verify JSON report is valid JSON + const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8'); + const jsonData = JSON.parse(jsonContent); + expect(jsonData.moduleName).toBe('mod-search'); + expect(jsonData.criteria).toBeInstanceOf(Array); + }); + + test('should evaluate mod-source-record-storage (Java backend module)', async () => { + const repoUrl = 'https://github.com/folio-org/mod-source-record-storage'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + + // Verify evaluation result structure + expect(result).toBeDefined(); + expect(result.moduleName).toBe('mod-source-record-storage'); + expect(result.repositoryUrl).toBe(repoUrl); + expect(result.evaluatedAt).toBeDefined(); + expect(result.language).toBe('Java'); + expect(result.criteria).toBeInstanceOf(Array); + expect(result.criteria.length).toBeGreaterThan(0); + + // Generate reports + const reportGenerator = new ReportGenerator(testOutputDir); + const reportPaths = await reportGenerator.generateReports(result); + + // Verify reports were generated + expect(reportPaths.htmlPath).toBeDefined(); + expect(reportPaths.jsonPath).toBeDefined(); + + // Verify JSON structure + const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8'); + const jsonData = JSON.parse(jsonContent); + expect(jsonData.moduleName).toBe('mod-source-record-storage'); + expect(jsonData.language).toBe('Java'); + expect(jsonData.criteria).toBeInstanceOf(Array); + }); + + test('should evaluate ui-data-import (JavaScript/UI module)', async () => { + const repoUrl = 'https://github.com/folio-org/ui-data-import'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + + // Verify evaluation result structure + expect(result).toBeDefined(); + expect(result.moduleName).toBe('ui-data-import'); + expect(result.repositoryUrl).toBe(repoUrl); + expect(result.evaluatedAt).toBeDefined(); + expect(result.language).toBe('JavaScript'); + expect(result.criteria).toBeInstanceOf(Array); + expect(result.criteria.length).toBeGreaterThan(0); + + // Generate reports + const reportGenerator = new ReportGenerator(testOutputDir); + const reportPaths = await reportGenerator.generateReports(result); + + // Verify reports were generated + expect(reportPaths.htmlPath).toBeDefined(); + expect(reportPaths.jsonPath).toBeDefined(); + + // Verify JSON structure + const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8'); + const jsonData = JSON.parse(jsonContent); + expect(jsonData.moduleName).toBe('ui-data-import'); + expect(jsonData.language).toBe('JavaScript'); + expect(jsonData.criteria).toBeInstanceOf(Array); + }); + }); + + describe('Report Generation Options', () => { + test('should generate only JSON report when outputHtml is false', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + expect(result).toBeDefined(); + + // Generate only JSON report + const reportGenerator = new ReportGenerator(testOutputDir); + const reportOptions: ReportOptions = { + outputHtml: false, + outputJson: true, + outputDir: testOutputDir + }; + const reportPaths = await reportGenerator.generateReports(result, reportOptions); + + // Verify only JSON report was generated + expect(reportPaths.htmlPath).toBeUndefined(); + expect(reportPaths.jsonPath).toBeDefined(); + + // Verify JSON file exists + const jsonExists = await fs.pathExists(reportPaths.jsonPath!); + expect(jsonExists).toBe(true); + }); + + test('should generate only HTML report when outputJson is false', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + expect(result).toBeDefined(); + + // Generate only HTML report + const reportGenerator = new ReportGenerator(testOutputDir); + const reportOptions: ReportOptions = { + outputHtml: true, + outputJson: false, + outputDir: testOutputDir + }; + const reportPaths = await reportGenerator.generateReports(result, reportOptions); + + // Verify only HTML report was generated + expect(reportPaths.htmlPath).toBeDefined(); + expect(reportPaths.jsonPath).toBeUndefined(); + + // Verify HTML file exists + const htmlExists = await fs.pathExists(reportPaths.htmlPath!); + expect(htmlExists).toBe(true); + }); + }); + + describe('Cleanup Functionality', () => { + test('should clean up temporary clone directory by default', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + await evaluator.evaluateModule(repoUrl); + + // Check that no temporary directories remain in system temp + const tempDir = path.join(os.tmpdir(), 'folio-eval'); + if (await fs.pathExists(tempDir)) { + const dirs = await fs.readdir(tempDir); + // There should be very few or no mod-search directories + const modSearchDirs = dirs.filter(d => d.startsWith('mod-search')); + expect(modSearchDirs.length).toBeLessThanOrEqual(1); + } + }); + + test('should not clean up when skipCleanup is true', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const tempDir = path.join(os.tmpdir(), 'folio-eval-test-nocleanup'); + + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: true, + tempDir: tempDir, + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + await evaluator.evaluateModule(repoUrl); + + // Check that temporary directory still exists + expect(await fs.pathExists(tempDir)).toBe(true); + const dirs = await fs.readdir(tempDir); + const modSearchDirs = dirs.filter(d => d.startsWith('mod-search')); + expect(modSearchDirs.length).toBeGreaterThan(0); + + // Clean up manually + for (const dir of modSearchDirs) { + await fs.remove(path.join(tempDir, dir)); + } + }); + }); + + describe('Criteria Filtering', () => { + test('should filter results when criteria filter is provided', async () => { + const repoUrl = 'https://github.com/folio-org/mod-search'; + const config: EvaluationConfig = { + outputDir: testOutputDir, + skipCleanup: false, + criteriaFilter: ['A001', 'S001'], + }; + + // Create evaluator with config + const evaluator = new ModuleEvaluator(config); + + // Run evaluation + const result = await evaluator.evaluateModule(repoUrl); + + // Verify only specified criteria are in results + expect(result.criteria.length).toBeLessThanOrEqual(2); + result.criteria.forEach((r) => { + expect(['A001', 'S001']).toContain(r.criterionId); + }); + }); + }); +}); diff --git a/src/utils/parsers/maven-dependency-parser.ts b/src/utils/parsers/maven-dependency-parser.ts index c270c76..3ec185e 100644 --- a/src/utils/parsers/maven-dependency-parser.ts +++ b/src/utils/parsers/maven-dependency-parser.ts @@ -35,6 +35,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; +import { parseStringPromise } from 'xml2js'; import { Dependency, DependencyExtractionResult, DependencyExtractionError } from '../../types'; import { normalizeLicenseName } from '../license-policy'; import { isNonEmptyString, isValidDependency } from '../type-guards'; @@ -54,6 +55,9 @@ const MAVEN_COORDINATE_PATTERN = /^(.+?):(.+?):(.+?)$/; // Maven build file patterns const MAVEN_BUILD_FILES = ['pom.xml']; +// Valid Maven packaging types +const VALID_PACKAGING_TYPES = ['pom', 'jar', 'war', 'ear', 'maven-plugin', 'ejb', 'rar', 'bundle']; + /** * Safely read file contents * @param filePath - Path to file @@ -108,6 +112,54 @@ function validateRepoPath(repoPath: string): string | null { } } +/** + * Determine the Maven packaging type from pom.xml using proper XML parsing + * @param repoPath - Path to Maven project + * @returns Promise resolving to packaging type ('pom', 'jar', 'war', etc.) or 'jar' as default + */ +async function getMavenPackaging(repoPath: string): Promise { + try { + const pomPath = path.join(repoPath, 'pom.xml'); + if (!fs.existsSync(pomPath)) { + console.warn('pom.xml not found, defaulting to jar packaging'); + return 'jar'; + } + + const pomContent = fs.readFileSync(pomPath, 'utf-8'); + + // Parse XML using xml2js for robust handling of XML variations + const parsed = await parseStringPromise(pomContent, { + trim: true, + explicitArray: true, + mergeAttrs: false + }); + + // Extract packaging from parsed XML + // Structure: parsed.project.packaging[0] + const packaging = parsed?.project?.packaging?.[0]; + + if (packaging && isNonEmptyString(packaging)) { + const trimmedPackaging = packaging.trim(); + + // Validate against known packaging types + if (!VALID_PACKAGING_TYPES.includes(trimmedPackaging)) { + console.warn(`Unknown Maven packaging type: ${trimmedPackaging}, defaulting to jar`); + return 'jar'; + } + + console.log(`Detected Maven packaging type: ${trimmedPackaging}`); + return trimmedPackaging; + } + + // Maven defaults to 'jar' when packaging is not specified + console.log('No packaging specified in pom.xml, defaulting to jar'); + return 'jar'; + } catch (error) { + console.warn('Failed to parse pom.xml packaging, defaulting to jar:', error); + return 'jar'; + } +} + /** * Split a Maven license string that may contain multiple licenses separated by pipe character * This is Maven-specific format for dual licensing: "Apache-2.0|MIT" @@ -343,9 +395,21 @@ export async function getMavenDependencies(repoPath: string): Promise