diff --git a/src/cli.ts b/src/cli.ts index 59ed5b5..066f402 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ Commands: Download options: --output Required. Directory to extract analyzer DLLs into --detect-using TFM detection input (URL, path, channel, or version) + Channels: latest, preview --tfm Explicit TFM (skips auto-detection) --version ALCops package version (default: latest) --detect-from Force detection source (bc-artifact, marketplace, diff --git a/src/detectors/nuget-devtools.ts b/src/detectors/nuget-devtools.ts index 3c67c46..1ee7cfb 100644 --- a/src/detectors/nuget-devtools.ts +++ b/src/detectors/nuget-devtools.ts @@ -1,149 +1,149 @@ -import * as https from 'https'; -import { TfmDetectionResult, TFM_PREFERENCE, NUGET_FLAT_CONTAINER } from '../types'; -import { readZipEOCD, fetchRange, parseZipCentralDirectory, extractRemoteZipCentralEntry, ZipCentralEntry } from '../http-range'; -import { detectTfmFromBuffer } from '../binary-tfm'; -import { Logger, nullLogger } from '../logger'; - -const DEVTOOLS_PACKAGE = 'microsoft.dynamics.businesscentral.development.tools'; -const CODE_ANALYSIS_DLL = 'Microsoft.Dynamics.Nav.CodeAnalysis.dll'; - -/** - * Fetch JSON from a URL, following redirects up to maxRedirects. - */ -function fetchJson(url: string, maxRedirects = 5): Promise> { - return new Promise((resolve, reject) => { - https.get(url, (res) => { - if ( - res.statusCode && - res.statusCode >= 300 && - res.statusCode < 400 && - res.headers.location - ) { - if (maxRedirects <= 0) { - reject(new Error('Too many redirects')); - return; - } - fetchJson(res.headers.location, maxRedirects - 1).then(resolve, reject); - return; - } - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode} for ${url}`)); - return; - } - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); - } catch { - reject(new Error(`Invalid JSON from ${url}`)); - } - }); - res.on('error', reject); - }).on('error', reject); - }); -} - -/** - * Resolve the DevTools version: 'latest', 'prerelease', or specific. - */ -export async function resolveDevToolsVersion(requested: string, logger: Logger = nullLogger): Promise { - if (requested !== 'latest' && requested !== 'prerelease') { - logger.info(`Using specified DevTools version: ${requested}`); - return requested; - } - logger.info(`Resolving DevTools version: '${requested}'`); - const url = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/index.json`; - logger.debug(`NuGet index URL: ${url}`); - const data = await fetchJson(url); - const versions = data.versions as string[]; - if (requested === 'latest') { - const stable = versions.filter((v) => !v.includes('-')); - const resolved = stable[stable.length - 1]; - logger.info(`Resolved to: ${resolved}`); - return resolved; - } - const resolved = versions[versions.length - 1]; - logger.info(`Resolved to: ${resolved}`); - return resolved; -} - -/** - * Select the best CodeAnalysis DLL entry from central directory entries. - * Entries are in `lib/{tfm}/Microsoft.Dynamics.Nav.CodeAnalysis.dll` format. - * Returns the entry whose TFM is highest in TFM_PREFERENCE order. - */ -export function selectBestDllEntry(entries: ZipCentralEntry[]): ZipCentralEntry | undefined { - const dllEntries = entries.filter( - (e) => e.fileName.endsWith(`/${CODE_ANALYSIS_DLL}`) || e.fileName.endsWith(`\\${CODE_ANALYSIS_DLL}`), - ); - - if (dllEntries.length === 0) { - return undefined; - } - - // Parse TFM from path and sort by preference - const ranked = dllEntries - .map((entry) => { - const parts = entry.fileName.replace(/\\/g, '/').split('/'); - // Expected: lib/{tfm}/filename.dll - const tfm = parts.length >= 3 ? parts[parts.length - 2] : null; - const preferenceIndex = tfm ? TFM_PREFERENCE.indexOf(tfm) : -1; - return { entry, tfm, preferenceIndex }; - }) - .filter((r) => r.tfm !== null) - .sort((a, b) => { - // Prefer entries that appear in TFM_PREFERENCE (lower index = more preferred) - if (a.preferenceIndex >= 0 && b.preferenceIndex >= 0) { - return a.preferenceIndex - b.preferenceIndex; - } - if (a.preferenceIndex >= 0) return -1; - if (b.preferenceIndex >= 0) return 1; - return 0; - }); - - return ranked.length > 0 ? ranked[0].entry : dllEntries[0]; -} - -/** - * Detect TFM from a NuGet DevTools package. - * Extracts the CodeAnalysis DLL via HTTP Range requests and reads TFM from the binary. - */ -export async function detectFromNuGetDevTools(version: string, logger: Logger = nullLogger): Promise { - const resolved = await resolveDevToolsVersion(version, logger); - - const nupkgUrl = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/${resolved}/${DEVTOOLS_PACKAGE}.${resolved}.nupkg`; - logger.info(`Reading NuGet package: ${nupkgUrl}`); - - // Read central directory via HTTP Range requests - const eocd = await readZipEOCD(nupkgUrl, logger); - const cdBytes = await fetchRange( - nupkgUrl, - eocd.centralDirectoryOffset, - eocd.centralDirectoryOffset + eocd.centralDirectorySize - 1, - logger, - ); - const entries = parseZipCentralDirectory(cdBytes); - logger.debug(`Package contains ${entries.length} entries`); - - // Find the best CodeAnalysis DLL entry - const bestEntry = selectBestDllEntry(entries); - if (!bestEntry) { - throw new Error(`${CODE_ANALYSIS_DLL} not found in NuGet package`); - } - logger.info(`Selected DLL entry: ${bestEntry.fileName}`); - - // Extract and binary search for TFM - const dllBuffer = await extractRemoteZipCentralEntry(nupkgUrl, bestEntry, logger); - const tfm = detectTfmFromBuffer(dllBuffer); - if (!tfm) { - throw new Error(`Could not detect TFM from ${bestEntry.fileName}`); - } - - logger.info(`Detected TFM: ${tfm} (from DevTools ${resolved})`); - return { - tfm, - source: 'nuget-devtools', - details: `DevTools ${resolved} → ${bestEntry.fileName}`, - }; -} +import * as https from 'https'; +import { TfmDetectionResult, TFM_PREFERENCE, NUGET_FLAT_CONTAINER } from '../types'; +import { readZipEOCD, fetchRange, parseZipCentralDirectory, extractRemoteZipCentralEntry, ZipCentralEntry } from '../http-range'; +import { detectTfmFromBuffer } from '../binary-tfm'; +import { Logger, nullLogger } from '../logger'; + +const DEVTOOLS_PACKAGE = 'microsoft.dynamics.businesscentral.development.tools'; +const CODE_ANALYSIS_DLL = 'Microsoft.Dynamics.Nav.CodeAnalysis.dll'; + +/** + * Fetch JSON from a URL, following redirects up to maxRedirects. + */ +function fetchJson(url: string, maxRedirects = 5): Promise> { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + fetchJson(res.headers.location, maxRedirects - 1).then(resolve, reject); + return; + } + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } catch { + reject(new Error(`Invalid JSON from ${url}`)); + } + }); + res.on('error', reject); + }).on('error', reject); + }); +} + +/** + * Resolve the DevTools version: 'latest', 'prerelease', or specific. + */ +export async function resolveDevToolsVersion(requested: string, logger: Logger = nullLogger): Promise { + if (requested !== 'latest' && requested !== 'preview') { + logger.info(`Using specified DevTools version: ${requested}`); + return requested; + } + logger.info(`Resolving DevTools version: '${requested}'`); + const url = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/index.json`; + logger.debug(`NuGet index URL: ${url}`); + const data = await fetchJson(url); + const versions = data.versions as string[]; + if (requested === 'latest') { + const stable = versions.filter((v) => !v.includes('-')); + const resolved = stable[stable.length - 1]; + logger.info(`Resolved to: ${resolved}`); + return resolved; + } + const resolved = versions[versions.length - 1]; + logger.info(`Resolved to: ${resolved}`); + return resolved; +} + +/** + * Select the best CodeAnalysis DLL entry from central directory entries. + * Entries are in `lib/{tfm}/Microsoft.Dynamics.Nav.CodeAnalysis.dll` format. + * Returns the entry whose TFM is highest in TFM_PREFERENCE order. + */ +export function selectBestDllEntry(entries: ZipCentralEntry[]): ZipCentralEntry | undefined { + const dllEntries = entries.filter( + (e) => e.fileName.endsWith(`/${CODE_ANALYSIS_DLL}`) || e.fileName.endsWith(`\\${CODE_ANALYSIS_DLL}`), + ); + + if (dllEntries.length === 0) { + return undefined; + } + + // Parse TFM from path and sort by preference + const ranked = dllEntries + .map((entry) => { + const parts = entry.fileName.replace(/\\/g, '/').split('/'); + // Expected: lib/{tfm}/filename.dll + const tfm = parts.length >= 3 ? parts[parts.length - 2] : null; + const preferenceIndex = tfm ? TFM_PREFERENCE.indexOf(tfm) : -1; + return { entry, tfm, preferenceIndex }; + }) + .filter((r) => r.tfm !== null) + .sort((a, b) => { + // Prefer entries that appear in TFM_PREFERENCE (lower index = more preferred) + if (a.preferenceIndex >= 0 && b.preferenceIndex >= 0) { + return a.preferenceIndex - b.preferenceIndex; + } + if (a.preferenceIndex >= 0) return -1; + if (b.preferenceIndex >= 0) return 1; + return 0; + }); + + return ranked.length > 0 ? ranked[0].entry : dllEntries[0]; +} + +/** + * Detect TFM from a NuGet DevTools package. + * Extracts the CodeAnalysis DLL via HTTP Range requests and reads TFM from the binary. + */ +export async function detectFromNuGetDevTools(version: string, logger: Logger = nullLogger): Promise { + const resolved = await resolveDevToolsVersion(version, logger); + + const nupkgUrl = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/${resolved}/${DEVTOOLS_PACKAGE}.${resolved}.nupkg`; + logger.info(`Reading NuGet package: ${nupkgUrl}`); + + // Read central directory via HTTP Range requests + const eocd = await readZipEOCD(nupkgUrl, logger); + const cdBytes = await fetchRange( + nupkgUrl, + eocd.centralDirectoryOffset, + eocd.centralDirectoryOffset + eocd.centralDirectorySize - 1, + logger, + ); + const entries = parseZipCentralDirectory(cdBytes); + logger.debug(`Package contains ${entries.length} entries`); + + // Find the best CodeAnalysis DLL entry + const bestEntry = selectBestDllEntry(entries); + if (!bestEntry) { + throw new Error(`${CODE_ANALYSIS_DLL} not found in NuGet package`); + } + logger.info(`Selected DLL entry: ${bestEntry.fileName}`); + + // Extract and binary search for TFM + const dllBuffer = await extractRemoteZipCentralEntry(nupkgUrl, bestEntry, logger); + const tfm = detectTfmFromBuffer(dllBuffer); + if (!tfm) { + throw new Error(`Could not detect TFM from ${bestEntry.fileName}`); + } + + logger.info(`Detected TFM: ${tfm} (from DevTools ${resolved})`); + return { + tfm, + source: 'nuget-devtools', + details: `DevTools ${resolved} → ${bestEntry.fileName}`, + }; +} diff --git a/src/resolve-detect-source.ts b/src/resolve-detect-source.ts index 1a8a80c..e305c36 100644 --- a/src/resolve-detect-source.ts +++ b/src/resolve-detect-source.ts @@ -33,9 +33,10 @@ export async function resolveDetectSource( return { source: 'bc-artifact', input }; } - if (isChannelKeyword(input)) { - logger.debug(`Input classified as channel keyword → nuget-devtools`); - return { source: 'nuget-devtools', input }; + const channel = resolveChannelKeyword(input); + if (channel) { + logger.debug(`Input '${input}' classified as channel '${channel}' → nuget-devtools`); + return { source: 'nuget-devtools', input: channel }; } // For version strings, resolve via API calls @@ -98,8 +99,16 @@ function isUrl(input: string): boolean { return input.startsWith('http://') || input.startsWith('https://'); } -const CHANNEL_KEYWORDS = new Set(['latest', 'prerelease', 'current']); +export const CHANNEL_ALIASES: Record = { + 'latest': 'latest', + 'current': 'latest', + 'stable': 'latest', + 'preview': 'preview', + 'next': 'preview', + 'prerelease': 'preview', + 'beta': 'preview', +}; -function isChannelKeyword(input: string): boolean { - return CHANNEL_KEYWORDS.has(input.toLowerCase()); +function resolveChannelKeyword(input: string): string | undefined { + return CHANNEL_ALIASES[input.toLowerCase()]; } diff --git a/tests/detectors/nuget-devtools.test.ts b/tests/detectors/nuget-devtools.test.ts index a48ac9b..299b1b2 100644 --- a/tests/detectors/nuget-devtools.test.ts +++ b/tests/detectors/nuget-devtools.test.ts @@ -1,198 +1,198 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { IncomingMessage } from 'http'; -import { Readable } from 'stream'; - -// Mock https for resolveDevToolsVersion (fetchJson) -vi.mock('https', () => ({ - get: vi.fn(), -})); - -// Mock http-range for central directory parsing -vi.mock('../../src/http-range', () => ({ - readZipEOCD: vi.fn(), - fetchRange: vi.fn(), - parseZipCentralDirectory: vi.fn(), - extractRemoteZipCentralEntry: vi.fn(), -})); - -// Mock binary-tfm for TFM detection -vi.mock('../../src/binary-tfm', () => ({ - detectTfmFromBuffer: vi.fn(), -})); - -import * as https from 'https'; -import { readZipEOCD, fetchRange, parseZipCentralDirectory, extractRemoteZipCentralEntry } from '../../src/http-range'; -import { detectTfmFromBuffer } from '../../src/binary-tfm'; -import { resolveDevToolsVersion, detectFromNuGetDevTools, selectBestDllEntry } from '../../src/detectors/nuget-devtools'; -import type { ZipCentralEntry } from '../../src/http-range'; - -const mockReadEOCD = vi.mocked(readZipEOCD); -const mockFetchRange = vi.mocked(fetchRange); -const mockParseCentralDir = vi.mocked(parseZipCentralDirectory); -const mockExtractEntry = vi.mocked(extractRemoteZipCentralEntry); -const mockDetectTfm = vi.mocked(detectTfmFromBuffer); - -function createMockResponse(body: object, statusCode = 200): IncomingMessage { - const readable = new Readable({ - read() { - this.push(JSON.stringify(body)); - this.push(null); - }, - }); - (readable as IncomingMessage).statusCode = statusCode; - (readable as IncomingMessage).headers = {}; - return readable as IncomingMessage; -} - -function mockHttpsGet(response: IncomingMessage) { - (https.get as ReturnType).mockImplementation((_url: string, callback: (res: IncomingMessage) => void) => { - callback(response); - return { on: vi.fn() }; - }); -} - -const sampleVersions = { - versions: [ - '14.0.11111.0', - '15.0.22222.0', - '16.0.33333.0', - '25.0.12345.0', - '26.0.54321.0', - '26.1.0.0-preview1', - ], -}; - -describe('resolveDevToolsVersion', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns a specific version as-is without calling NuGet', async () => { - const result = await resolveDevToolsVersion('26.0.12345.0'); - expect(result).toBe('26.0.12345.0'); - expect(https.get).not.toHaveBeenCalled(); - }); - - it('resolves "latest" to the last stable (non-prerelease) version', async () => { - mockHttpsGet(createMockResponse(sampleVersions)); - const result = await resolveDevToolsVersion('latest'); - expect(result).toBe('26.0.54321.0'); - }); - - it('resolves "prerelease" to the very last version including pre-release', async () => { - mockHttpsGet(createMockResponse(sampleVersions)); - const result = await resolveDevToolsVersion('prerelease'); - expect(result).toBe('26.1.0.0-preview1'); - }); -}); - -describe('selectBestDllEntry', () => { - it('selects entry with highest preference TFM', () => { - const entries: ZipCentralEntry[] = [ - { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 300, compressionMethod: 8 }, - ]; - const result = selectBestDllEntry(entries); - expect(result?.fileName).toContain('net8.0'); - }); - - it('returns undefined when no DLL entries match', () => { - const entries: ZipCentralEntry[] = [ - { fileName: 'lib/net8.0/SomeOther.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]; - expect(selectBestDllEntry(entries)).toBeUndefined(); - }); - - it('handles entries with unknown TFM', () => { - const entries: ZipCentralEntry[] = [ - { fileName: 'lib/unknownTfm/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]; - const result = selectBestDllEntry(entries); - expect(result?.fileName).toContain('unknownTfm'); - }); -}); - -describe('detectFromNuGetDevTools', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('detects TFM via HTTP Range + binary search pipeline', async () => { - // Mock EOCD + central directory - mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 2 }); - mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); - mockParseCentralDir.mockReturnValue([ - { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 300, compressionMethod: 8 }, - ]); - mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); - mockDetectTfm.mockReturnValue('net8.0'); - - const result = await detectFromNuGetDevTools('26.0.12345.0'); - - expect(result.tfm).toBe('net8.0'); - expect(result.source).toBe('nuget-devtools'); - expect(result.details).toContain('26.0.12345.0'); - expect(mockReadEOCD).toHaveBeenCalled(); - expect(mockExtractEntry).toHaveBeenCalled(); - expect(mockDetectTfm).toHaveBeenCalled(); - }); - - it('resolves "latest" and detects TFM correctly', async () => { - // First: resolve version - mockHttpsGet(createMockResponse(sampleVersions)); - - // Then: HTTP Range pipeline - mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); - mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); - mockParseCentralDir.mockReturnValue([ - { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]); - mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); - mockDetectTfm.mockReturnValue('net8.0'); - - const result = await detectFromNuGetDevTools('latest'); - expect(result.tfm).toBe('net8.0'); - expect(result.source).toBe('nuget-devtools'); - expect(result.details).toContain('26.0.54321.0'); - }); - - it('throws when CodeAnalysis DLL is not in package', async () => { - mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); - mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); - mockParseCentralDir.mockReturnValue([ - { fileName: 'lib/net8.0/SomeOther.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]); - - await expect(detectFromNuGetDevTools('26.0.12345.0')).rejects.toThrow( - 'not found in NuGet package', - ); - }); - - it('throws when TFM cannot be detected from DLL', async () => { - mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); - mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); - mockParseCentralDir.mockReturnValue([ - { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]); - mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); - mockDetectTfm.mockReturnValue(null); - - await expect(detectFromNuGetDevTools('26.0.12345.0')).rejects.toThrow( - 'Could not detect TFM', - ); - }); - - it('always sets source to "nuget-devtools"', async () => { - mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); - mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); - mockParseCentralDir.mockReturnValue([ - { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, - ]); - mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); - mockDetectTfm.mockReturnValue('netstandard2.0'); - - const result = await detectFromNuGetDevTools('15.0.12345.0'); - expect(result.source).toBe('nuget-devtools'); - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { IncomingMessage } from 'http'; +import { Readable } from 'stream'; + +// Mock https for resolveDevToolsVersion (fetchJson) +vi.mock('https', () => ({ + get: vi.fn(), +})); + +// Mock http-range for central directory parsing +vi.mock('../../src/http-range', () => ({ + readZipEOCD: vi.fn(), + fetchRange: vi.fn(), + parseZipCentralDirectory: vi.fn(), + extractRemoteZipCentralEntry: vi.fn(), +})); + +// Mock binary-tfm for TFM detection +vi.mock('../../src/binary-tfm', () => ({ + detectTfmFromBuffer: vi.fn(), +})); + +import * as https from 'https'; +import { readZipEOCD, fetchRange, parseZipCentralDirectory, extractRemoteZipCentralEntry } from '../../src/http-range'; +import { detectTfmFromBuffer } from '../../src/binary-tfm'; +import { resolveDevToolsVersion, detectFromNuGetDevTools, selectBestDllEntry } from '../../src/detectors/nuget-devtools'; +import type { ZipCentralEntry } from '../../src/http-range'; + +const mockReadEOCD = vi.mocked(readZipEOCD); +const mockFetchRange = vi.mocked(fetchRange); +const mockParseCentralDir = vi.mocked(parseZipCentralDirectory); +const mockExtractEntry = vi.mocked(extractRemoteZipCentralEntry); +const mockDetectTfm = vi.mocked(detectTfmFromBuffer); + +function createMockResponse(body: object, statusCode = 200): IncomingMessage { + const readable = new Readable({ + read() { + this.push(JSON.stringify(body)); + this.push(null); + }, + }); + (readable as IncomingMessage).statusCode = statusCode; + (readable as IncomingMessage).headers = {}; + return readable as IncomingMessage; +} + +function mockHttpsGet(response: IncomingMessage) { + (https.get as ReturnType).mockImplementation((_url: string, callback: (res: IncomingMessage) => void) => { + callback(response); + return { on: vi.fn() }; + }); +} + +const sampleVersions = { + versions: [ + '14.0.11111.0', + '15.0.22222.0', + '16.0.33333.0', + '25.0.12345.0', + '26.0.54321.0', + '26.1.0.0-preview1', + ], +}; + +describe('resolveDevToolsVersion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a specific version as-is without calling NuGet', async () => { + const result = await resolveDevToolsVersion('26.0.12345.0'); + expect(result).toBe('26.0.12345.0'); + expect(https.get).not.toHaveBeenCalled(); + }); + + it('resolves "latest" to the last stable (non-prerelease) version', async () => { + mockHttpsGet(createMockResponse(sampleVersions)); + const result = await resolveDevToolsVersion('latest'); + expect(result).toBe('26.0.54321.0'); + }); + + it('resolves "preview" to the very last version including pre-release', async () => { + mockHttpsGet(createMockResponse(sampleVersions)); + const result = await resolveDevToolsVersion('preview'); + expect(result).toBe('26.1.0.0-preview1'); + }); +}); + +describe('selectBestDllEntry', () => { + it('selects entry with highest preference TFM', () => { + const entries: ZipCentralEntry[] = [ + { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 300, compressionMethod: 8 }, + ]; + const result = selectBestDllEntry(entries); + expect(result?.fileName).toContain('net8.0'); + }); + + it('returns undefined when no DLL entries match', () => { + const entries: ZipCentralEntry[] = [ + { fileName: 'lib/net8.0/SomeOther.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]; + expect(selectBestDllEntry(entries)).toBeUndefined(); + }); + + it('handles entries with unknown TFM', () => { + const entries: ZipCentralEntry[] = [ + { fileName: 'lib/unknownTfm/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]; + const result = selectBestDllEntry(entries); + expect(result?.fileName).toContain('unknownTfm'); + }); +}); + +describe('detectFromNuGetDevTools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects TFM via HTTP Range + binary search pipeline', async () => { + // Mock EOCD + central directory + mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 2 }); + mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); + mockParseCentralDir.mockReturnValue([ + { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 300, compressionMethod: 8 }, + ]); + mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); + mockDetectTfm.mockReturnValue('net8.0'); + + const result = await detectFromNuGetDevTools('26.0.12345.0'); + + expect(result.tfm).toBe('net8.0'); + expect(result.source).toBe('nuget-devtools'); + expect(result.details).toContain('26.0.12345.0'); + expect(mockReadEOCD).toHaveBeenCalled(); + expect(mockExtractEntry).toHaveBeenCalled(); + expect(mockDetectTfm).toHaveBeenCalled(); + }); + + it('resolves "latest" and detects TFM correctly', async () => { + // First: resolve version + mockHttpsGet(createMockResponse(sampleVersions)); + + // Then: HTTP Range pipeline + mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); + mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); + mockParseCentralDir.mockReturnValue([ + { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]); + mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); + mockDetectTfm.mockReturnValue('net8.0'); + + const result = await detectFromNuGetDevTools('latest'); + expect(result.tfm).toBe('net8.0'); + expect(result.source).toBe('nuget-devtools'); + expect(result.details).toContain('26.0.54321.0'); + }); + + it('throws when CodeAnalysis DLL is not in package', async () => { + mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); + mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); + mockParseCentralDir.mockReturnValue([ + { fileName: 'lib/net8.0/SomeOther.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]); + + await expect(detectFromNuGetDevTools('26.0.12345.0')).rejects.toThrow( + 'not found in NuGet package', + ); + }); + + it('throws when TFM cannot be detected from DLL', async () => { + mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); + mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); + mockParseCentralDir.mockReturnValue([ + { fileName: 'lib/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]); + mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); + mockDetectTfm.mockReturnValue(null); + + await expect(detectFromNuGetDevTools('26.0.12345.0')).rejects.toThrow( + 'Could not detect TFM', + ); + }); + + it('always sets source to "nuget-devtools"', async () => { + mockReadEOCD.mockResolvedValue({ centralDirectoryOffset: 1000, centralDirectorySize: 500, entryCount: 1 }); + mockFetchRange.mockResolvedValue(Buffer.from('fake-cd-data')); + mockParseCentralDir.mockReturnValue([ + { fileName: 'lib/netstandard2.1/Microsoft.Dynamics.Nav.CodeAnalysis.dll', compressedSize: 100, uncompressedSize: 200, localHeaderOffset: 0, compressionMethod: 8 }, + ]); + mockExtractEntry.mockResolvedValue(Buffer.from('fake-dll')); + mockDetectTfm.mockReturnValue('netstandard2.0'); + + const result = await detectFromNuGetDevTools('15.0.12345.0'); + expect(result.source).toBe('nuget-devtools'); + }); +}); diff --git a/tests/resolve-detect-source.test.ts b/tests/resolve-detect-source.test.ts index bc8113f..6fd391e 100644 --- a/tests/resolve-detect-source.test.ts +++ b/tests/resolve-detect-source.test.ts @@ -15,6 +15,7 @@ import { queryMarketplace } from '../src/detectors/marketplace'; import { resolveDetectSource, resolveVersionSource, + CHANNEL_ALIASES, } from '../src/resolve-detect-source'; import type { RegistrationVersion } from '../src/types'; @@ -47,14 +48,35 @@ describe('resolveDetectSource', () => { expect(result).toEqual({ source: 'nuget-devtools', input: 'latest' }); }); - it('classifies "prerelease" as nuget-devtools channel', async () => { + it('classifies "prerelease" as nuget-devtools channel normalized to "preview"', async () => { const result = await resolveDetectSource('prerelease'); - expect(result).toEqual({ source: 'nuget-devtools', input: 'prerelease' }); + expect(result).toEqual({ source: 'nuget-devtools', input: 'preview' }); }); - it('classifies "current" as nuget-devtools channel', async () => { + it('classifies "current" as nuget-devtools channel normalized to "latest"', async () => { const result = await resolveDetectSource('current'); - expect(result).toEqual({ source: 'nuget-devtools', input: 'current' }); + expect(result).toEqual({ source: 'nuget-devtools', input: 'latest' }); + }); + + it.each([ + ['stable', 'latest'], + ['next', 'preview'], + ['preview', 'preview'], + ['beta', 'preview'], + ])('classifies "%s" as nuget-devtools channel normalized to "%s"', async (input, expected) => { + const result = await resolveDetectSource(input); + expect(result).toEqual({ source: 'nuget-devtools', input: expected }); + }); + + it('normalizes channel keywords case-insensitively', async () => { + const result = await resolveDetectSource('LATEST'); + expect(result).toEqual({ source: 'nuget-devtools', input: 'latest' }); + }); + + it('exports CHANNEL_ALIASES with all expected keys', () => { + expect(Object.keys(CHANNEL_ALIASES).sort()).toEqual( + ['beta', 'current', 'latest', 'next', 'prerelease', 'preview', 'stable'].sort(), + ); }); it('resolves a NuGet DevTools version via API', async () => {