/** * Native Composer CLI Testing * Tests the Composer registry implementation using the actual composer CLI */ import { expect, tap } from '@git.zone/tstest/tapbundle'; import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside'; import { SmartRegistry } from '../ts/index.js'; import { createTestRegistry, createTestTokens, createComposerZip } from './helpers/registry.js'; import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js'; import * as http from 'http'; import * as url from 'url'; import * as fs from 'fs'; import * as path from 'path'; // Test context let registry: SmartRegistry; let server: http.Server; let registryUrl: string; let registryPort: number; let composerToken: string; let testDir: string; let composerHome: string; /** * Create HTTP server wrapper around SmartRegistry */ async function createHttpServer( registryInstance: SmartRegistry, port: number ): Promise<{ server: http.Server; url: string }> { return new Promise((resolve, reject) => { const httpServer = http.createServer(async (req, res) => { try { // Parse request const parsedUrl = url.parse(req.url || '', true); const pathname = parsedUrl.pathname || '/'; const query = parsedUrl.query; // Read body const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(chunk); } const bodyBuffer = Buffer.concat(chunks); // Parse body based on content type let body: any; if (bodyBuffer.length > 0) { const contentType = req.headers['content-type'] || ''; if (contentType.includes('application/json')) { try { body = JSON.parse(bodyBuffer.toString('utf-8')); } catch (error) { body = bodyBuffer; } } else { body = bodyBuffer; } } // Convert to IRequestContext const context: IRequestContext = { method: req.method || 'GET', path: pathname, headers: req.headers as Record, query: query as Record, body: body, }; // Handle request const response: IResponse = await registryInstance.handleRequest(context); // Convert IResponse to HTTP response res.statusCode = response.status; // Set headers for (const [key, value] of Object.entries(response.headers || {})) { res.setHeader(key, value); } // Send body if (response.body) { if (Buffer.isBuffer(response.body)) { res.end(response.body); } else if (typeof response.body === 'string') { res.end(response.body); } else { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(response.body)); } } else { res.end(); } } catch (error) { console.error('Server error:', error); res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: String(error) })); } }); httpServer.listen(port, () => { const serverUrl = `http://localhost:${port}`; resolve({ server: httpServer, url: serverUrl }); }); httpServer.on('error', reject); }); } /** * Setup Composer auth.json for authentication */ function setupComposerAuth( token: string, composerHomeArg: string, serverUrl: string, port: number ): string { fs.mkdirSync(composerHomeArg, { recursive: true }); const authJson = { 'http-basic': { [`localhost:${port}`]: { username: 'testuser', password: token, }, }, }; const authPath = path.join(composerHomeArg, 'auth.json'); fs.writeFileSync(authPath, JSON.stringify(authJson, null, 2), 'utf-8'); return authPath; } /** * Create a Composer project that uses our registry */ function createComposerProject( projectDir: string, serverUrl: string ): void { fs.mkdirSync(projectDir, { recursive: true }); const composerJson = { name: 'test/consumer-project', description: 'Test consumer project for Composer CLI tests', type: 'project', require: {}, repositories: [ { type: 'composer', url: `${serverUrl}/composer`, }, ], config: { 'secure-http': false, }, }; fs.writeFileSync( path.join(projectDir, 'composer.json'), JSON.stringify(composerJson, null, 2), 'utf-8' ); } /** * Run Composer command with custom home directory */ async function runComposerCommand( command: string, cwd: string ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const fullCommand = `cd "${cwd}" && COMPOSER_HOME="${composerHome}" composer ${command}`; try { const result = await tapNodeTools.runCommand(fullCommand); return { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.exitCode || 0, }; } catch (error: any) { return { stdout: error.stdout || '', stderr: error.stderr || String(error), exitCode: error.exitCode || 1, }; } } /** * Upload a Composer package via HTTP API */ async function uploadComposerPackage( vendorPackage: string, version: string, token: string, serverUrl: string ): Promise { const zipData = await createComposerZip(vendorPackage, version); const response = await fetch(`${serverUrl}/composer/packages/${vendorPackage}`, { method: 'PUT', headers: { 'Content-Type': 'application/zip', Authorization: `Bearer ${token}`, }, body: zipData, }); if (!response.ok) { const body = await response.text(); throw new Error(`Failed to upload package: ${response.status} ${body}`); } } /** * Cleanup test directory */ function cleanupTestDir(dir: string): void { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } // ======================================================================== // TESTS // ======================================================================== tap.test('Composer CLI: should verify composer is installed', async () => { try { const result = await tapNodeTools.runCommand('composer --version'); console.log('Composer version output:', result.stdout.substring(0, 200)); expect(result.exitCode).toEqual(0); } catch (error) { console.log('Composer CLI not available, skipping native CLI tests'); // Skip remaining tests if Composer is not installed tap.skip.test('Composer CLI: remaining tests skipped - composer not available'); return; } }); tap.test('Composer CLI: should setup registry and HTTP server', async () => { // Create registry registry = await createTestRegistry(); const tokens = await createTestTokens(registry); composerToken = tokens.composerToken; expect(registry).toBeInstanceOf(SmartRegistry); expect(composerToken).toBeTypeOf('string'); // Use port 38000 (avoids conflicts with other tests) registryPort = 38000; const serverSetup = await createHttpServer(registry, registryPort); server = serverSetup.server; registryUrl = serverSetup.url; expect(server).toBeDefined(); expect(registryUrl).toEqual(`http://localhost:${registryPort}`); // Setup test directory testDir = path.join(process.cwd(), '.nogit', 'test-composer-cli'); cleanupTestDir(testDir); fs.mkdirSync(testDir, { recursive: true }); // Setup COMPOSER_HOME directory composerHome = path.join(testDir, '.composer'); fs.mkdirSync(composerHome, { recursive: true }); // Setup Composer auth const authPath = setupComposerAuth(composerToken, composerHome, registryUrl, registryPort); expect(fs.existsSync(authPath)).toEqual(true); }); tap.test('Composer CLI: should verify server is responding', async () => { // Check server is up by doing a direct HTTP request const response = await fetch(`${registryUrl}/composer/packages.json`); expect(response.status).toBeGreaterThanOrEqual(200); expect(response.status).toBeLessThan(500); }); tap.test('Composer CLI: should upload a package via API', async () => { const vendorPackage = 'testvendor/test-package'; const version = '1.0.0'; await uploadComposerPackage(vendorPackage, version, composerToken, registryUrl); // Verify package exists via packages.json const response = await fetch(`${registryUrl}/composer/packages.json`); expect(response.status).toEqual(200); const packagesJson = await response.json(); expect(packagesJson.packages).toBeDefined(); expect(packagesJson.packages[vendorPackage]).toBeDefined(); }); tap.test('Composer CLI: should require package from registry', async () => { const projectDir = path.join(testDir, 'consumer-project'); createComposerProject(projectDir, registryUrl); // Try to require the package we uploaded const result = await runComposerCommand( 'require testvendor/test-package:1.0.0 --no-interaction', projectDir ); console.log('composer require output:', result.stdout); console.log('composer require stderr:', result.stderr); expect(result.exitCode).toEqual(0); }); tap.test('Composer CLI: should verify package in vendor directory', async () => { const projectDir = path.join(testDir, 'consumer-project'); const packageDir = path.join(projectDir, 'vendor', 'testvendor', 'test-package'); expect(fs.existsSync(packageDir)).toEqual(true); // Check composer.json exists in package const packageComposerPath = path.join(packageDir, 'composer.json'); expect(fs.existsSync(packageComposerPath)).toEqual(true); }); tap.test('Composer CLI: should upload second version', async () => { const vendorPackage = 'testvendor/test-package'; const version = '2.0.0'; await uploadComposerPackage(vendorPackage, version, composerToken, registryUrl); // Verify both versions exist const response = await fetch(`${registryUrl}/composer/packages.json`); const packagesJson = await response.json(); expect(packagesJson.packages[vendorPackage]['1.0.0']).toBeDefined(); expect(packagesJson.packages[vendorPackage]['2.0.0']).toBeDefined(); }); tap.test('Composer CLI: should update to new version', async () => { const projectDir = path.join(testDir, 'consumer-project'); // Update to version 2.0.0 const result = await runComposerCommand( 'require testvendor/test-package:2.0.0 --no-interaction', projectDir ); console.log('composer update output:', result.stdout); expect(result.exitCode).toEqual(0); // Verify composer.lock has the new version const lockPath = path.join(projectDir, 'composer.lock'); expect(fs.existsSync(lockPath)).toEqual(true); const lockContent = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); const pkg = lockContent.packages.find((p: any) => p.name === 'testvendor/test-package'); expect(pkg?.version).toEqual('2.0.0'); }); tap.test('Composer CLI: should search for packages', async () => { const projectDir = path.join(testDir, 'consumer-project'); // Search for packages (may not work on all Composer versions) const result = await runComposerCommand( 'search testvendor --no-interaction 2>&1 || true', projectDir ); console.log('composer search output:', result.stdout); // Search may or may not work depending on registry implementation // Just verify it doesn't crash expect(result.exitCode).toBeLessThanOrEqual(1); }); tap.test('Composer CLI: should show package info', async () => { const projectDir = path.join(testDir, 'consumer-project'); const result = await runComposerCommand( 'show testvendor/test-package --no-interaction', projectDir ); console.log('composer show output:', result.stdout); expect(result.exitCode).toEqual(0); expect(result.stdout).toContain('testvendor/test-package'); }); tap.test('Composer CLI: should remove package', async () => { const projectDir = path.join(testDir, 'consumer-project'); const result = await runComposerCommand( 'remove testvendor/test-package --no-interaction', projectDir ); console.log('composer remove output:', result.stdout); expect(result.exitCode).toEqual(0); // Verify package is removed from vendor const packageDir = path.join(projectDir, 'vendor', 'testvendor', 'test-package'); expect(fs.existsSync(packageDir)).toEqual(false); }); tap.postTask('cleanup composer cli tests', async () => { // Stop server if (server) { await new Promise((resolve) => { server.close(() => resolve()); }); } // Cleanup test directory if (testDir) { cleanupTestDir(testDir); } // Destroy registry if (registry) { registry.destroy(); } }); export default tap.start();