426 lines
12 KiB
TypeScript
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();
|