Files
smartregistry/test/test.composer.nativecli.node.ts

426 lines
12 KiB
TypeScript

/**
* 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<string, string>,
query: query as Record<string, string>,
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<void> {
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<void>((resolve) => {
server.close(() => resolve());
});
}
// Cleanup test directory
if (testDir) {
cleanupTestDir(testDir);
}
// Destroy registry
if (registry) {
registry.destroy();
}
});
export default tap.start();