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 () => {