/** * Native gem CLI Testing * Tests the RubyGems registry implementation using the actual gem 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, createRubyGem } 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 rubygemsToken: string; let testDir: string; let gemHome: 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 gem credentials file * Format: YAML with :rubygems_api_key: TOKEN */ function setupGemCredentials(token: string, gemHomeArg: string): string { const gemDir = path.join(gemHomeArg, '.gem'); fs.mkdirSync(gemDir, { recursive: true }); // Create credentials file in YAML format const credentialsContent = `:rubygems_api_key: ${token}\n`; const credentialsPath = path.join(gemDir, 'credentials'); fs.writeFileSync(credentialsPath, credentialsContent, 'utf-8'); // Set restrictive permissions (gem requires 0600) fs.chmodSync(credentialsPath, 0o600); return credentialsPath; } /** * Create a test gem file */ async function createTestGemFile( gemName: string, version: string, targetDir: string ): Promise { const gemData = await createRubyGem(gemName, version); const gemFilename = `${gemName}-${version}.gem`; const gemPath = path.join(targetDir, gemFilename); fs.writeFileSync(gemPath, gemData); return gemPath; } /** * Run gem command with proper environment */ async function runGemCommand( command: string, cwd: string, includeAuth: boolean = true ): Promise<{ stdout: string; stderr: string; exitCode: number }> { // Prepare environment variables const envVars = [ `HOME="${gemHome}"`, `GEM_HOME="${gemHome}"`, includeAuth ? '' : 'RUBYGEMS_API_KEY=""', ].filter(Boolean).join(' '); // Build command with cd to correct directory and environment variables const fullCommand = `cd "${cwd}" && ${envVars} ${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, }; } } /** * Cleanup test directory */ function cleanupTestDir(dir: string): void { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } // ======================================================================== // TESTS // ======================================================================== tap.test('RubyGems CLI: should setup registry and HTTP server', async () => { // Create registry registry = await createTestRegistry(); const tokens = await createTestTokens(registry); rubygemsToken = tokens.rubygemsToken; expect(registry).toBeInstanceOf(SmartRegistry); expect(rubygemsToken).toBeTypeOf('string'); // Use port 36000 (avoids npm:35000, cargo:5000 conflicts) registryPort = 36000; 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-rubygems-cli'); cleanupTestDir(testDir); fs.mkdirSync(testDir, { recursive: true }); // Setup GEM_HOME gemHome = path.join(testDir, '.gem-home'); fs.mkdirSync(gemHome, { recursive: true }); // Setup gem credentials const credentialsPath = setupGemCredentials(rubygemsToken, gemHome); expect(fs.existsSync(credentialsPath)).toEqual(true); // Verify credentials file has correct permissions const stats = fs.statSync(credentialsPath); const mode = stats.mode & 0o777; expect(mode).toEqual(0o600); }); tap.test('RubyGems CLI: should verify server is responding', async () => { // Check server is up by doing a direct HTTP request to the Compact Index const response = await fetch(`${registryUrl}/rubygems/versions`); expect(response.status).toBeGreaterThanOrEqual(200); expect(response.status).toBeLessThan(500); }); tap.test('RubyGems CLI: should build and push a gem', async () => { const gemName = 'test-gem-cli'; const version = '1.0.0'; const gemPath = await createTestGemFile(gemName, version, testDir); expect(fs.existsSync(gemPath)).toEqual(true); const result = await runGemCommand( `gem push ${gemPath} --host ${registryUrl}/rubygems`, testDir ); console.log('gem push output:', result.stdout); console.log('gem push stderr:', result.stderr); expect(result.exitCode).toEqual(0); expect(result.stdout || result.stderr).toContain(gemName); }); tap.test('RubyGems CLI: should verify gem in Compact Index /versions', async () => { const gemName = 'test-gem-cli'; const response = await fetch(`${registryUrl}/rubygems/versions`); expect(response.status).toEqual(200); const versionsData = await response.text(); console.log('Versions data:', versionsData); // Format: GEMNAME VERSION[,VERSION...] MD5 expect(versionsData).toContain(gemName); expect(versionsData).toContain('1.0.0'); }); tap.test('RubyGems CLI: should verify gem in Compact Index /info file', async () => { const gemName = 'test-gem-cli'; const response = await fetch(`${registryUrl}/rubygems/info/${gemName}`); expect(response.status).toEqual(200); const infoData = await response.text(); console.log('Info data:', infoData); // Format: VERSION [DEPS]|REQS expect(infoData).toContain('1.0.0'); }); tap.test('RubyGems CLI: should download gem file', async () => { const gemName = 'test-gem-cli'; const version = '1.0.0'; const response = await fetch(`${registryUrl}/rubygems/gems/${gemName}-${version}.gem`); expect(response.status).toEqual(200); const gemData = await response.arrayBuffer(); expect(gemData.byteLength).toBeGreaterThan(0); // Verify content type expect(response.headers.get('content-type')).toContain('application/octet-stream'); }); tap.test('RubyGems CLI: should fetch gem metadata JSON', async () => { const gemName = 'test-gem-cli'; const response = await fetch(`${registryUrl}/rubygems/api/v1/versions/${gemName}.json`); expect(response.status).toEqual(200); const metadata = await response.json(); console.log('Metadata:', metadata); expect(metadata).toBeInstanceOf(Array); expect(metadata.length).toBeGreaterThan(0); expect(metadata[0].number).toEqual('1.0.0'); }); tap.test('RubyGems CLI: should push second version', async () => { const gemName = 'test-gem-cli'; const version = '2.0.0'; const gemPath = await createTestGemFile(gemName, version, testDir); const result = await runGemCommand( `gem push ${gemPath} --host ${registryUrl}/rubygems`, testDir ); console.log('gem push v2.0.0 output:', result.stdout); expect(result.exitCode).toEqual(0); }); tap.test('RubyGems CLI: should list all versions in /versions file', async () => { const gemName = 'test-gem-cli'; const response = await fetch(`${registryUrl}/rubygems/versions`); expect(response.status).toEqual(200); const versionsData = await response.text(); console.log('All versions data:', versionsData); // Should contain both versions expect(versionsData).toContain(gemName); expect(versionsData).toContain('1.0.0'); expect(versionsData).toContain('2.0.0'); }); tap.test('RubyGems CLI: should yank a version', async () => { const gemName = 'test-gem-cli'; const version = '1.0.0'; const result = await runGemCommand( `gem yank ${gemName} -v ${version} --host ${registryUrl}/rubygems`, testDir ); console.log('gem yank output:', result.stdout); console.log('gem yank stderr:', result.stderr); expect(result.exitCode).toEqual(0); // Verify version is yanked in /versions file // Yanked versions are prefixed with '-' const response = await fetch(`${registryUrl}/rubygems/versions`); const versionsData = await response.text(); console.log('Versions after yank:', versionsData); // Yanked version should have '-' prefix expect(versionsData).toContain('-1.0.0'); }); tap.test('RubyGems CLI: should unyank a version', async () => { const gemName = 'test-gem-cli'; const version = '1.0.0'; const result = await runGemCommand( `gem yank ${gemName} -v ${version} --undo --host ${registryUrl}/rubygems`, testDir ); console.log('gem unyank output:', result.stdout); console.log('gem unyank stderr:', result.stderr); expect(result.exitCode).toEqual(0); // Verify version is not yanked in /versions file const response = await fetch(`${registryUrl}/rubygems/versions`); const versionsData = await response.text(); console.log('Versions after unyank:', versionsData); // Should not have '-' prefix anymore (or have both without prefix) // Check that we have the version without yank marker const lines = versionsData.trim().split('\n'); const gemLine = lines.find(line => line.startsWith(gemName)); if (gemLine) { // Parse format: "gemname version[,version...] md5" const parts = gemLine.split(' '); const versions = parts[1]; // Should have 1.0.0 without '-' prefix expect(versions).toContain('1.0.0'); expect(versions).not.toContain('-1.0.0'); } }); tap.test('RubyGems CLI: should fetch dependencies', async () => { const gemName = 'test-gem-cli'; const response = await fetch(`${registryUrl}/rubygems/api/v1/dependencies?gems=${gemName}`); expect(response.status).toEqual(200); const dependencies = await response.json(); console.log('Dependencies:', dependencies); expect(dependencies).toBeInstanceOf(Array); }); tap.test('RubyGems CLI: should fail to push without auth', async () => { const gemName = 'unauth-gem'; const version = '1.0.0'; const gemPath = await createTestGemFile(gemName, version, testDir); // Run without auth const result = await runGemCommand( `gem push ${gemPath} --host ${registryUrl}/rubygems`, testDir, false ); console.log('gem push unauth output:', result.stdout); console.log('gem push unauth stderr:', result.stderr); // Should fail with auth error expect(result.exitCode).not.toEqual(0); }); tap.postTask('cleanup rubygems 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();