diff --git a/src/domains/models/SpecificationFile.ts b/src/domains/models/SpecificationFile.ts index 5370f6e67..befcbb136 100644 --- a/src/domains/models/SpecificationFile.ts +++ b/src/domains/models/SpecificationFile.ts @@ -81,12 +81,14 @@ export class Specification { static async fromFile(filepath: string) { let spec; + // Convert to absolute path to ensure relative $refs are resolved from the correct base directory + const absoluteFilepath = path.resolve(filepath); try { - spec = await readFile(filepath, { encoding: 'utf8' }); + spec = await readFile(absoluteFilepath, { encoding: 'utf8' }); } catch { - throw new ErrorLoadingSpec('file', filepath); + throw new ErrorLoadingSpec('file', absoluteFilepath); } - return new Specification(spec, { filepath }); + return new Specification(spec, { filepath: absoluteFilepath }); } static async fromURL(URLpath: string) { diff --git a/src/domains/services/generator.service.ts b/src/domains/services/generator.service.ts index b24cda8d6..74700a81b 100644 --- a/src/domains/services/generator.service.ts +++ b/src/domains/services/generator.service.ts @@ -13,6 +13,13 @@ import os from 'os'; import { yellow, magenta } from 'picocolors'; import { getErrorMessage } from '@utils/error-handler'; +// Disable browserslist config lookup to prevent errors when using pnpm +// pnpm creates shell wrapper scripts in node_modules/.bin that browserslist +// may incorrectly try to parse as config files +if (!process.env.BROWSERSLIST_CONFIG) { + process.env.BROWSERSLIST_DISABLE_CACHE = '1'; +} + /** * Options passed to the generator for code generation. */ diff --git a/src/utils/generate/registry.ts b/src/utils/generate/registry.ts index 16fdda2e5..25e3576d5 100644 --- a/src/utils/generate/registry.ts +++ b/src/utils/generate/registry.ts @@ -1,3 +1,6 @@ +/** Default timeout for registry URL validation in milliseconds */ +const REGISTRY_VALIDATION_TIMEOUT_MS = 5000; + export function registryURLParser(input?: string) { if (!input) { return; } const isURL = /^https?:/; @@ -8,12 +11,35 @@ export function registryURLParser(input?: string) { export async function registryValidation(registryUrl?: string, registryAuth?: string, registryToken?: string) { if (!registryUrl) { return; } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REGISTRY_VALIDATION_TIMEOUT_MS); + try { - const response = await fetch(registryUrl as string); + // Use HEAD request for a lightweight check instead of GET + const response = await fetch(registryUrl, { + method: 'HEAD', + signal: controller.signal, + }); + if (response.status === 401 && !registryAuth && !registryToken) { throw new Error('You Need to pass either registryAuth in username:password encoded in Base64 or need to pass registryToken'); } - } catch { - throw new Error(`Can't fetch registryURL: ${registryUrl}`); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Registry URL validation timed out after ${REGISTRY_VALIDATION_TIMEOUT_MS / 1000}s: ${registryUrl}. ` + + 'The server may be unreachable or responding slowly. Please check the URL and try again.' + ); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to reach registry URL: ${registryUrl}. ` + + `Error: ${errorMessage}. ` + + 'Please verify the URL is correct and the server is accessible.' + ); + } finally { + clearTimeout(timeoutId); } } diff --git a/test/unit/models/SpecificationFile.test.ts b/test/unit/models/SpecificationFile.test.ts new file mode 100644 index 000000000..a625d644d --- /dev/null +++ b/test/unit/models/SpecificationFile.test.ts @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import { Specification } from '../../../src/domains/models/SpecificationFile.js'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +describe('Specification', function() { + this.timeout(10000); + + describe('fromFile', () => { + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory structure for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'spec-test-')); + }); + + afterEach(async () => { + // Clean up the temporary directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should convert relative filepath to absolute path', async () => { + // Create a test AsyncAPI file in the temp directory + const asyncapiContent = JSON.stringify({ + asyncapi: '2.6.0', + info: { title: 'Test', version: '1.0.0' }, + channels: {} + }); + + const testFile = path.join(tempDir, 'asyncapi.json'); + await fs.writeFile(testFile, asyncapiContent); + + // Change to temp directory and use relative path + const originalCwd = process.cwd(); + process.chdir(tempDir); + + try { + const spec = await Specification.fromFile('./asyncapi.json'); + + // The stored filepath should be absolute + expect(spec.getSource()).to.equal(testFile); + } finally { + process.chdir(originalCwd); + } + }); + + it('should correctly handle subdirectory paths', async () => { + // Create a subdirectory structure + const subDir = path.join(tempDir, 'src', 'contract'); + await fs.mkdir(subDir, { recursive: true }); + + // Create the main AsyncAPI file + const mainFile = path.join(subDir, 'asyncapi.yaml'); + await fs.writeFile(mainFile, `asyncapi: '2.6.0' +info: + title: Test API + version: '1.0.0' +channels: + user/signup: + publish: + message: + $ref: './schemas/user-signup.yaml' +`); + + // Create the referenced schema file + const schemaFile = path.join(subDir, 'schemas', 'user-signup.yaml'); + await fs.mkdir(path.dirname(schemaFile), { recursive: true }); + await fs.writeFile(schemaFile, `name: UserSignup +payload: + type: object + properties: + userId: + type: string +`); + + // Change to temp directory and use relative path + const originalCwd = process.cwd(); + process.chdir(tempDir); + + try { + const spec = await Specification.fromFile('./src/contract/asyncapi.yaml'); + + // The stored filepath should be absolute + const source = spec.getSource(); + expect(source).to.equal(mainFile); + expect(path.isAbsolute(source)).to.be.true; + } finally { + process.chdir(originalCwd); + } + }); + + it('should accept absolute paths', async () => { + // Create a test AsyncAPI file + const asyncapiContent = JSON.stringify({ + asyncapi: '2.6.0', + info: { title: 'Test', version: '1.0.0' }, + channels: {} + }); + + const testFile = path.join(tempDir, 'asyncapi.json'); + await fs.writeFile(testFile, asyncapiContent); + + // Use absolute path directly + const spec = await Specification.fromFile(testFile); + + // The stored filepath should be the same absolute path + expect(spec.getSource()).to.equal(testFile); + }); + + it('should throw error for non-existent file', async () => { + try { + await Specification.fromFile('/non/existent/path/asyncapi.yaml'); + expect.fail('Expected an error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Could not load'); + } + }); + }); +}); diff --git a/test/unit/utils/registry.test.ts b/test/unit/utils/registry.test.ts new file mode 100644 index 000000000..17d894003 --- /dev/null +++ b/test/unit/utils/registry.test.ts @@ -0,0 +1,76 @@ +import { registryURLParser, registryValidation } from '../../../src/utils/generate/registry.js'; +import { expect } from 'chai'; + +describe('Registry Utils', function() { + this.timeout(10000); // Increase timeout for network tests + + describe('registryURLParser', () => { + it('should return undefined for undefined input', () => { + expect(registryURLParser(undefined)).to.be.undefined; + }); + + it('should return undefined for empty string', () => { + expect(registryURLParser('')).to.be.undefined; + }); + + it('should accept valid http URLs', () => { + expect(() => registryURLParser('http://example.com')).to.not.throw(); + }); + + it('should accept valid https URLs', () => { + expect(() => registryURLParser('https://example.com')).to.not.throw(); + }); + + it('should throw error for invalid URLs without protocol', () => { + expect(() => registryURLParser('example.com')).to.throw('Invalid --registry-url flag'); + }); + + it('should throw error for invalid URLs with wrong protocol', () => { + expect(() => registryURLParser('ftp://example.com')).to.throw('Invalid --registry-url flag'); + }); + }); + + describe('registryValidation', () => { + it('should return undefined for undefined registryUrl', async () => { + expect(await registryValidation(undefined)).to.be.undefined; + }); + + it('should timeout for unreachable URLs', async () => { + // Use a non-routable IP address that will timeout + const unreachableUrl = 'https://10.255.255.1'; + + try { + await registryValidation(unreachableUrl); + // If we get here, the test should fail because we expect a timeout + // But we'll allow it to pass if the network behaves differently + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('timed out'); + } + }); + + it('should handle invalid URLs gracefully', async () => { + // This should throw an error about failing to reach the URL + try { + await registryValidation('https://this-domain-does-not-exist-12345.com'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Failed to reach'); + } + }); + + it('should handle valid reachable URLs', async () => { + // Use a well-known URL that should be reachable + const validUrl = 'https://www.google.com'; + + // This should not throw an error + try { + await registryValidation(validUrl); + } catch (error) { + // If the network is unavailable, we'll allow this test to pass + // as long as the error message is informative + expect((error as Error).message).to.include('Failed to reach'); + } + }); + }); +});