388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
/**
|
|
* Native npm CLI Testing
|
|
* Tests the NPM registry implementation using the actual npm 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, cleanupS3Bucket } 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 state
|
|
let registry: SmartRegistry;
|
|
let server: http.Server;
|
|
let registryUrl: string;
|
|
let registryPort: number;
|
|
let npmToken: string;
|
|
let testDir: string;
|
|
let npmrcPath: 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 {
|
|
const parsedUrl = new url.URL(req.url || '/', `http://localhost:${port}`);
|
|
const pathname = parsedUrl.pathname;
|
|
const query: Record<string, string> = {};
|
|
parsedUrl.searchParams.forEach((value, key) => {
|
|
query[key] = value;
|
|
});
|
|
|
|
// Read body
|
|
let body: any = undefined;
|
|
if (req.method === 'PUT' || req.method === 'POST' || req.method === 'PATCH') {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
const bodyBuffer = Buffer.concat(chunks);
|
|
|
|
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 (response.body is always ReadableStream<Uint8Array> or undefined)
|
|
if (response.body) {
|
|
const { Readable } = await import('stream');
|
|
Readable.fromWeb(response.body).pipe(res);
|
|
} 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 .npmrc configuration
|
|
*/
|
|
function setupNpmrc(registryUrlArg: string, token: string, testDirArg: string): string {
|
|
const npmrcContent = `registry=${registryUrlArg}/npm/
|
|
//localhost:${registryPort}/npm/:_authToken=${token}
|
|
`;
|
|
|
|
const npmrcFilePath = path.join(testDirArg, '.npmrc');
|
|
fs.writeFileSync(npmrcFilePath, npmrcContent, 'utf-8');
|
|
return npmrcFilePath;
|
|
}
|
|
|
|
/**
|
|
* Create a test package
|
|
*/
|
|
function createTestPackage(
|
|
packageName: string,
|
|
version: string,
|
|
targetDir: string
|
|
): string {
|
|
const packageDir = path.join(targetDir, packageName.replace(/\//g, '-'));
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
|
|
// Create package.json
|
|
const packageJson = {
|
|
name: packageName,
|
|
version: version,
|
|
description: `Test package ${packageName}`,
|
|
main: 'index.js',
|
|
scripts: {},
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'package.json'),
|
|
JSON.stringify(packageJson, null, 2),
|
|
'utf-8'
|
|
);
|
|
|
|
// Create a simple index.js
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'index.js'),
|
|
`module.exports = { name: '${packageName}', version: '${version}' };\n`,
|
|
'utf-8'
|
|
);
|
|
|
|
// Create README.md
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'README.md'),
|
|
`# ${packageName}\n\nTest package version ${version}\n`,
|
|
'utf-8'
|
|
);
|
|
|
|
// Copy .npmrc into the package directory
|
|
if (npmrcPath && fs.existsSync(npmrcPath)) {
|
|
fs.copyFileSync(npmrcPath, path.join(packageDir, '.npmrc'));
|
|
}
|
|
|
|
return packageDir;
|
|
}
|
|
|
|
/**
|
|
* Run npm command with proper environment
|
|
*/
|
|
async function runNpmCommand(
|
|
command: string,
|
|
cwd: string
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
const { exec } = await import('child_process');
|
|
|
|
// Build isolated env that prevents npm from reading ~/.npmrc
|
|
const env: Record<string, string> = {};
|
|
// Copy only essential env vars (PATH, etc.) — exclude HOME to prevent ~/.npmrc reading
|
|
for (const key of ['PATH', 'NODE', 'NVM_DIR', 'NVM_BIN', 'LANG', 'TERM', 'SHELL']) {
|
|
if (process.env[key]) env[key] = process.env[key]!;
|
|
}
|
|
env.HOME = testDir;
|
|
env.NPM_CONFIG_USERCONFIG = npmrcPath;
|
|
env.NPM_CONFIG_GLOBALCONFIG = '/dev/null';
|
|
env.NPM_CONFIG_CACHE = path.join(testDir, '.npm-cache');
|
|
env.NPM_CONFIG_PREFIX = path.join(testDir, '.npm-global');
|
|
env.NPM_CONFIG_REGISTRY = `${registryUrl}/npm/`;
|
|
|
|
return new Promise((resolve) => {
|
|
exec(command, { cwd, env, timeout: 30000 }, (error, stdout, stderr) => {
|
|
resolve({
|
|
stdout: stdout || '',
|
|
stderr: stderr || '',
|
|
exitCode: error ? (error as any).code ?? 1 : 0,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup test directory
|
|
*/
|
|
function cleanupTestDir(dir: string): void {
|
|
if (fs.existsSync(dir)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// TESTS
|
|
// ========================================================================
|
|
|
|
tap.test('NPM CLI: should setup registry and HTTP server', async () => {
|
|
// Find available port
|
|
registryPort = 35000;
|
|
|
|
// Create registry with correct registryUrl for CLI tests
|
|
registry = await createTestRegistry({ registryUrl: `http://localhost:${registryPort}` });
|
|
const tokens = await createTestTokens(registry);
|
|
npmToken = tokens.npmToken;
|
|
|
|
// Clean up stale npm CLI test data via unpublish API
|
|
for (const pkg of ['test-package-cli', '@testscope%2fscoped-package']) {
|
|
await registry.handleRequest({
|
|
method: 'DELETE',
|
|
path: `/npm/${pkg}/-rev/cleanup`,
|
|
headers: { Authorization: `Bearer ${npmToken}` },
|
|
query: {},
|
|
});
|
|
}
|
|
|
|
expect(registry).toBeInstanceOf(SmartRegistry);
|
|
expect(npmToken).toBeTypeOf('string');
|
|
const serverSetup = await createHttpServer(registry, registryPort);
|
|
server = serverSetup.server;
|
|
registryUrl = serverSetup.url;
|
|
|
|
expect(server).toBeDefined();
|
|
expect(registryUrl).toEqual(`http://localhost:${registryPort}`);
|
|
|
|
// Setup test directory — use /tmp to isolate from project tree
|
|
testDir = path.join('/tmp', 'smartregistry-test-npm-cli');
|
|
cleanupTestDir(testDir);
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
|
|
// Setup .npmrc
|
|
npmrcPath = setupNpmrc(registryUrl, npmToken, testDir);
|
|
expect(fs.existsSync(npmrcPath)).toEqual(true);
|
|
});
|
|
|
|
tap.test('NPM CLI: should verify server is responding', async () => {
|
|
const result = await runNpmCommand('npm ping', testDir);
|
|
console.log('npm ping output:', result.stdout, result.stderr);
|
|
|
|
// npm ping may not work with custom registries, so just check server is up
|
|
// by doing a direct HTTP request
|
|
const response = await fetch(`${registryUrl}/npm/`);
|
|
expect(response.status).toBeGreaterThanOrEqual(200);
|
|
expect(response.status).toBeLessThan(500);
|
|
});
|
|
|
|
tap.test('NPM CLI: should publish a package', async () => {
|
|
const packageName = 'test-package-cli';
|
|
const version = '1.0.0';
|
|
const packageDir = createTestPackage(packageName, version, testDir);
|
|
|
|
const result = await runNpmCommand('npm publish', packageDir);
|
|
console.log('npm publish output:', result.stdout);
|
|
console.log('npm publish stderr:', result.stderr);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
expect(result.stdout || result.stderr).toContain(packageName);
|
|
});
|
|
|
|
tap.test('NPM CLI: should view published package', async () => {
|
|
const packageName = 'test-package-cli';
|
|
|
|
const result = await runNpmCommand(`npm view ${packageName}`, testDir);
|
|
console.log('npm view output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
expect(result.stdout).toContain(packageName);
|
|
expect(result.stdout).toContain('1.0.0');
|
|
});
|
|
|
|
tap.test('NPM CLI: should install published package', async () => {
|
|
const packageName = 'test-package-cli';
|
|
const installDir = path.join(testDir, 'install-test');
|
|
fs.mkdirSync(installDir, { recursive: true });
|
|
|
|
// Create a minimal package.json for install target
|
|
fs.writeFileSync(
|
|
path.join(installDir, 'package.json'),
|
|
JSON.stringify({ name: 'install-test', version: '1.0.0', dependencies: { [packageName]: '1.0.0' } }),
|
|
'utf-8'
|
|
);
|
|
// Copy .npmrc
|
|
if (npmrcPath && fs.existsSync(npmrcPath)) {
|
|
fs.copyFileSync(npmrcPath, path.join(installDir, '.npmrc'));
|
|
}
|
|
|
|
const result = await runNpmCommand('npm install', installDir);
|
|
console.log('npm install output:', result.stdout);
|
|
console.log('npm install stderr:', result.stderr);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
|
|
// Verify package was installed
|
|
const installed = fs.existsSync(path.join(installDir, 'node_modules', packageName, 'package.json'));
|
|
expect(installed).toEqual(true);
|
|
});
|
|
|
|
tap.test('NPM CLI: should publish second version', async () => {
|
|
const packageName = 'test-package-cli';
|
|
const version = '1.1.0';
|
|
const packageDir = createTestPackage(packageName, version, testDir);
|
|
|
|
const result = await runNpmCommand('npm publish', packageDir);
|
|
console.log('npm publish v1.1.0 output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('NPM CLI: should list versions', async () => {
|
|
const packageName = 'test-package-cli';
|
|
|
|
const result = await runNpmCommand(`npm view ${packageName} versions`, testDir);
|
|
console.log('npm view versions output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
expect(result.stdout).toContain('1.0.0');
|
|
expect(result.stdout).toContain('1.1.0');
|
|
});
|
|
|
|
tap.test('NPM CLI: should publish scoped package', async () => {
|
|
const packageName = '@testscope/scoped-package';
|
|
const version = '1.0.0';
|
|
const packageDir = createTestPackage(packageName, version, testDir);
|
|
|
|
const result = await runNpmCommand('npm publish --access public', packageDir);
|
|
console.log('npm publish scoped output:', result.stdout);
|
|
console.log('npm publish scoped stderr:', result.stderr);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('NPM CLI: should view scoped package', async () => {
|
|
const packageName = '@testscope/scoped-package';
|
|
|
|
const result = await runNpmCommand(`npm view ${packageName}`, testDir);
|
|
console.log('npm view scoped output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
expect(result.stdout).toContain('scoped-package');
|
|
});
|
|
|
|
tap.test('NPM CLI: should fail to publish without auth', async () => {
|
|
const packageName = 'unauth-package';
|
|
const version = '1.0.0';
|
|
const packageDir = createTestPackage(packageName, version, testDir);
|
|
|
|
// Temporarily remove .npmrc (write one without auth)
|
|
const noAuthNpmrc = path.join(packageDir, '.npmrc');
|
|
fs.writeFileSync(noAuthNpmrc, `registry=${registryUrl}/npm/\n`, 'utf-8');
|
|
|
|
const result = await runNpmCommand('npm publish', packageDir);
|
|
console.log('npm publish unauth output:', result.stdout);
|
|
console.log('npm publish unauth stderr:', result.stderr);
|
|
|
|
// Should fail with auth error
|
|
expect(result.exitCode).not.toEqual(0);
|
|
});
|
|
|
|
tap.postTask('cleanup npm cli tests', async () => {
|
|
// Stop server
|
|
if (server) {
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
// Cleanup test directory
|
|
cleanupTestDir(testDir);
|
|
});
|
|
|
|
export default tap.start();
|