fix(core): Normalize binary data handling across registries and add buffer helpers
This commit is contained in:
425
test/test.composer.nativecli.node.ts
Normal file
425
test/test.composer.nativecli.node.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user