From b573635c82002cd14adb36bd640bd0e74ea968a1 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 15:04:54 +0200 Subject: [PATCH] feat: add devtools extract and decompile CLI commands Add 'alcops devtools extract' command to download and extract BC DevTools from NuGet packages or VS Marketplace VSIX archives. Supports version resolution (latest/prerelease/specific), TFM selection for NuGet, and optional glob-based file filtering. Add 'alcops devtools decompile' command to decompile .NET assemblies using ILSpy CLI (ilspycmd via dotnet tool install). Configurable assembly list with sensible defaults (8 BC DevTools assemblies). Both commands output JSON to stdout with logs on stderr, matching existing CLI conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli.ts | 85 ++++++++++++ src/devtools/decompile-command.ts | 166 +++++++++++++++++++++++ src/devtools/extract-command.ts | 110 +++++++++++++++ src/devtools/package-extractor.ts | 115 ++++++++++++++++ tests/devtools/decompile-command.test.ts | 36 +++++ tests/devtools/extract-command.test.ts | 152 +++++++++++++++++++++ tests/devtools/package-extractor.test.ts | 164 ++++++++++++++++++++++ 7 files changed, 828 insertions(+) create mode 100644 src/devtools/decompile-command.ts create mode 100644 src/devtools/extract-command.ts create mode 100644 src/devtools/package-extractor.ts create mode 100644 tests/devtools/decompile-command.test.ts create mode 100644 tests/devtools/extract-command.test.ts create mode 100644 tests/devtools/package-extractor.test.ts diff --git a/src/cli.ts b/src/cli.ts index 59ed5b5..5da82a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,8 @@ import { detectFromMarketplace } from './detectors/marketplace'; import { detectFromNuGetDevTools } from './detectors/nuget-devtools'; import { detectFromCompilerPath } from './detectors/compiler-path'; import { executeDownload } from './download/download-command'; +import { executeDevToolsExtract, ExtractSource } from './devtools/extract-command'; +import { executeDecompile, DEFAULT_ASSEMBLIES } from './devtools/decompile-command'; import type { DetectSource } from './resolve-detect-source'; function usage(): void { @@ -16,6 +18,8 @@ Commands: detect-tfm nuget-devtools [version|channel] Detect TFM from NuGet DevTools (default: latest) detect-tfm compiler-path Detect TFM from a local compiler directory download --output Download and extract ALCops analyzers + devtools extract --source --output Download and extract BC DevTools + devtools decompile --input --output Decompile DLLs using ILSpy Download options: --output Required. Directory to extract analyzer DLLs into @@ -25,6 +29,19 @@ Download options: --detect-from Force detection source (bc-artifact, marketplace, nuget-devtools, compiler-path) +Devtools extract options: + --source Required. Package source + --version Version or channel (default: latest) + --tfm Target framework (required for nuget) + --output Required. Output directory + --include Filter extracted files (e.g. "*.dll") + +Devtools decompile options: + --input Required. Directory containing DLLs + --output Required. Output directory for decompiled projects + --assemblies Comma-separated assembly names (has defaults) + --keep-tool Don't clean up ilspycmd after decompilation + Global options: --verbose Enable debug-level logging --help Show this help message @@ -119,6 +136,68 @@ async function main(): Promise { ); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else if (command === 'devtools') { + const subcommand = filteredArgs[1]; + if (!subcommand) { + process.stderr.write('Error: devtools requires a subcommand (extract, decompile)\n'); + usage(); + process.exit(1); + } + + const subArgs = filteredArgs.slice(2); + + if (subcommand === 'extract') { + const source = getFlagValue(subArgs, '--source') as ExtractSource | undefined; + const version = getFlagValue(subArgs, '--version') ?? 'latest'; + const tfm = getFlagValue(subArgs, '--tfm'); + const outputDir = getFlagValue(subArgs, '--output'); + const includePattern = getFlagValue(subArgs, '--include'); + + if (!source || !isValidExtractSource(source)) { + process.stderr.write( + 'Error: --source is required for devtools extract\n', + ); + process.exit(1); + } + if (!outputDir) { + process.stderr.write('Error: --output is required for devtools extract\n'); + process.exit(1); + } + + const result = await executeDevToolsExtract( + { source, version, tfm, outputDir, includePattern }, + logger, + ); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else if (subcommand === 'decompile') { + const inputDir = getFlagValue(subArgs, '--input'); + const outputDir = getFlagValue(subArgs, '--output'); + const assembliesStr = getFlagValue(subArgs, '--assemblies'); + const keepTool = subArgs.includes('--keep-tool'); + + if (!inputDir) { + process.stderr.write('Error: --input is required for devtools decompile\n'); + process.exit(1); + } + if (!outputDir) { + process.stderr.write('Error: --output is required for devtools decompile\n'); + process.exit(1); + } + + const assemblies = assembliesStr + ? assembliesStr.split(',').map((a) => a.trim()) + : DEFAULT_ASSEMBLIES; + + const result = await executeDecompile( + { inputDir, outputDir, assemblies, keepTool }, + logger, + ); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else { + process.stderr.write(`Error: Unknown devtools subcommand: ${subcommand}\n`); + usage(); + process.exit(1); + } } else { process.stderr.write(`Error: Unknown command: ${command}\n`); usage(); @@ -138,6 +217,12 @@ function isValidDetectSource(value: string): value is DetectSource { return VALID_DETECT_SOURCES.has(value); } +const VALID_EXTRACT_SOURCES = new Set(['nuget', 'vsix']); + +function isValidExtractSource(value: string): value is ExtractSource { + return VALID_EXTRACT_SOURCES.has(value); +} + main().catch((err) => { process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); diff --git a/src/devtools/decompile-command.ts b/src/devtools/decompile-command.ts new file mode 100644 index 0000000..f99db50 --- /dev/null +++ b/src/devtools/decompile-command.ts @@ -0,0 +1,166 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { Logger, nullLogger } from '../logger'; + +export const DEFAULT_ASSEMBLIES = [ + 'Microsoft.Dynamics.Nav.AL.Common', + 'Microsoft.Dynamics.Nav.Analyzers.Common', + 'Microsoft.Dynamics.Nav.AppSourceCop', + 'Microsoft.Dynamics.Nav.CodeAnalysis', + 'Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces', + 'Microsoft.Dynamics.Nav.CodeCop', + 'Microsoft.Dynamics.Nav.PerTenantExtensionCop', + 'Microsoft.Dynamics.Nav.UICop', +]; + +export interface DecompileOptions { + inputDir: string; + outputDir: string; + assemblies: string[]; + keepTool: boolean; +} + +export interface AssemblyResult { + name: string; + status: 'success' | 'failed'; + outputDir?: string; + error?: string; +} + +export interface DecompileResult { + succeeded: number; + failed: number; + total: number; + outputDir: string; + assemblies: AssemblyResult[]; +} + +/** + * Decompile .NET assemblies using ILSpy CLI (ilspycmd). + * Requires the .NET SDK to be installed. + */ +export async function executeDecompile( + options: DecompileOptions, + logger: Logger = nullLogger, +): Promise { + const { inputDir, outputDir, assemblies, keepTool } = options; + + if (!fs.existsSync(inputDir)) { + throw new Error(`Input directory not found: ${inputDir}`); + } + + const dotnetPath = findDotnet(); + logger.info(`Found dotnet: ${dotnetPath}`); + + const toolPath = path.join(os.tmpdir(), `ilspycmd-${Date.now()}`); + const ilspycmd = path.join(toolPath, process.platform === 'win32' ? 'ilspycmd.exe' : 'ilspycmd'); + + try { + installIlSpy(dotnetPath, toolPath, logger); + verifyIlSpy(ilspycmd, logger); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const results: AssemblyResult[] = []; + let succeeded = 0; + let failed = 0; + + for (const assembly of assemblies) { + const dllPath = path.join(inputDir, `${assembly}.dll`); + + if (!fs.existsSync(dllPath)) { + logger.warn(`DLL not found, skipping: ${dllPath}`); + results.push({ name: assembly, status: 'failed', error: 'DLL not found' }); + failed++; + continue; + } + + const assemblyOutputDir = path.join(outputDir, assembly); + + // Clean previous output for idempotency + if (fs.existsSync(assemblyOutputDir)) { + fs.rmSync(assemblyOutputDir, { recursive: true, force: true }); + } + + logger.info(`Decompiling ${assembly}...`); + + try { + execFileSync(ilspycmd, [dllPath, '-p', '-o', assemblyOutputDir, '--nested-directories'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + logger.info(` -> ${assemblyOutputDir}`); + results.push({ + name: assembly, + status: 'success', + outputDir: path.resolve(assemblyOutputDir), + }); + succeeded++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to decompile ${assembly}: ${msg}`); + results.push({ name: assembly, status: 'failed', error: msg }); + failed++; + } + } + + return { + succeeded, + failed, + total: assemblies.length, + outputDir: path.resolve(outputDir), + assemblies: results, + }; + } finally { + if (!keepTool && fs.existsSync(toolPath)) { + logger.debug(`Cleaning up ilspycmd from ${toolPath}`); + fs.rmSync(toolPath, { recursive: true, force: true }); + } + } +} + +function findDotnet(): string { + const dotnetCmd = process.platform === 'win32' ? 'dotnet.exe' : 'dotnet'; + try { + execFileSync(dotnetCmd, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }); + return dotnetCmd; + } catch { + throw new Error( + 'dotnet SDK not found in PATH. Install the .NET SDK (https://dot.net/download) ' + + 'to use the decompile command.', + ); + } +} + +function installIlSpy(dotnetPath: string, toolPath: string, logger: Logger): void { + logger.info(`Installing ilspycmd to ${toolPath}...`); + try { + execFileSync( + dotnetPath, + ['tool', 'install', 'ilspycmd', '--tool-path', toolPath], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 }, + ); + logger.info('ilspycmd installed.'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to install ilspycmd: ${msg}`); + } +} + +function verifyIlSpy(ilspycmdPath: string, logger: Logger): void { + try { + const output = execFileSync(ilspycmdPath, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + logger.debug(`ilspycmd version: ${output.toString().trim()}`); + } catch { + throw new Error( + `ilspycmd is not functional at ${ilspycmdPath}. ` + + 'Try running the command again.', + ); + } +} diff --git a/src/devtools/extract-command.ts b/src/devtools/extract-command.ts new file mode 100644 index 0000000..5d446f0 --- /dev/null +++ b/src/devtools/extract-command.ts @@ -0,0 +1,110 @@ +import * as path from 'path'; +import { Logger, nullLogger } from '../logger'; +import { resolveDevToolsVersion } from '../detectors/nuget-devtools'; +import { resolveExtensionVersion } from '../detectors/marketplace'; +import { httpsGetBuffer } from '../http-client'; +import { getUserAgent } from '../user-agent'; +import { NUGET_FLAT_CONTAINER } from '../types'; +import { extractFromBuffer } from './package-extractor'; +import packageJson from '../../package.json'; + +const DEVTOOLS_PACKAGE = 'microsoft.dynamics.businesscentral.development.tools'; +const DEFAULT_USER_AGENT = getUserAgent('ALCops', packageJson.version); + +export type ExtractSource = 'nuget' | 'vsix'; + +export interface DevToolsExtractOptions { + source: ExtractSource; + version: string; + tfm?: string; + outputDir: string; + includePattern?: string; +} + +export interface DevToolsExtractResult { + version: string; + source: ExtractSource; + tfm?: string; + outputDir: string; + fileCount: number; + files: string[]; +} + +/** + * Download and extract BC DevTools files from NuGet or VS Marketplace VSIX. + */ +export async function executeDevToolsExtract( + options: DevToolsExtractOptions, + logger: Logger = nullLogger, +): Promise { + if (options.source === 'nuget') { + return extractFromNuGet(options, logger); + } + return extractFromVsix(options, logger); +} + +async function extractFromNuGet( + options: DevToolsExtractOptions, + logger: Logger, +): Promise { + if (!options.tfm) { + throw new Error('--tfm is required when source is "nuget" (e.g. net8.0, net10.0)'); + } + + const version = await resolveDevToolsVersion(options.version, logger); + logger.info(`Resolved DevTools version: ${version}`); + + const nupkgUrl = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/${version}/${DEVTOOLS_PACKAGE}.${version}.nupkg`; + logger.info(`Downloading NuGet package: ${nupkgUrl}`); + + const buffer = await httpsGetBuffer(nupkgUrl, DEFAULT_USER_AGENT); + logger.info(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)} MB`); + + const pathPrefix = `tools/${options.tfm}/any`; + const result = extractFromBuffer({ + zipBuffer: buffer, + pathPrefix, + outputDir: options.outputDir, + includePattern: options.includePattern, + logger, + }); + + return { + version, + source: 'nuget', + tfm: options.tfm, + outputDir: result.outputDir, + fileCount: result.fileCount, + files: result.files.map((f) => path.resolve(f)), + }; +} + +async function extractFromVsix( + options: DevToolsExtractOptions, + logger: Logger, +): Promise { + const channel = options.version || 'prerelease'; + const resolved = await resolveExtensionVersion(channel, logger); + logger.info(`Resolved extension version: ${resolved.version}`); + + logger.info(`Downloading VSIX: ${resolved.vsixUrl}`); + const buffer = await httpsGetBuffer(resolved.vsixUrl, DEFAULT_USER_AGENT); + logger.info(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)} MB`); + + const pathPrefix = 'extension/bin/Analyzers'; + const result = extractFromBuffer({ + zipBuffer: buffer, + pathPrefix, + outputDir: options.outputDir, + includePattern: options.includePattern, + logger, + }); + + return { + version: resolved.version, + source: 'vsix', + outputDir: result.outputDir, + fileCount: result.fileCount, + files: result.files.map((f) => path.resolve(f)), + }; +} diff --git a/src/devtools/package-extractor.ts b/src/devtools/package-extractor.ts new file mode 100644 index 0000000..3d97dae --- /dev/null +++ b/src/devtools/package-extractor.ts @@ -0,0 +1,115 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { unzipSync } from 'fflate'; +import { Logger, nullLogger } from '../logger'; + +export interface ExtractOptions { + /** Raw ZIP/nupkg/vsix buffer */ + zipBuffer: Buffer; + /** Path prefix to match inside the archive (e.g. 'tools/net8.0/any/') */ + pathPrefix: string; + /** Output directory to write extracted files */ + outputDir: string; + /** Optional glob pattern to filter filenames (simple *.ext matching) */ + includePattern?: string; + logger?: Logger; +} + +export interface ExtractResult { + outputDir: string; + files: string[]; + fileCount: number; +} + +/** + * Extract files from an in-memory ZIP buffer that match a given path prefix. + * Writes matched files flat into the output directory (preserving subdirectory structure + * relative to the prefix). + */ +export function extractFromBuffer(options: ExtractOptions): ExtractResult { + const { zipBuffer, pathPrefix, outputDir, includePattern, logger = nullLogger } = options; + + const normalized = normalizePrefixPath(pathPrefix); + logger.debug(`Extracting files with prefix '${normalized}' to ${outputDir}`); + + const unzipped = unzipSync(new Uint8Array(zipBuffer)); + const allEntries = Object.keys(unzipped); + logger.debug(`Archive contains ${allEntries.length} entries`); + + const matchingEntries = allEntries.filter((entry) => { + const normalizedEntry = entry.replace(/\\/g, '/'); + if (!normalizedEntry.startsWith(normalized)) return false; + // Skip directory entries (trailing slash or empty name after prefix) + const relativePath = normalizedEntry.slice(normalized.length); + if (relativePath.length === 0 || relativePath.endsWith('/')) return false; + // Apply include filter if specified + if (includePattern) { + const fileName = relativePath.split('/').pop()!; + if (!matchesGlob(fileName, includePattern)) return false; + } + return true; + }); + + if (matchingEntries.length === 0) { + const prefixEntries = allEntries + .filter((e) => e.replace(/\\/g, '/').startsWith(normalized.split('/')[0] + '/')) + .slice(0, 10); + logger.debug(`No entries match prefix '${normalized}'. Sample entries under '${normalized.split('/')[0]}/': ${prefixEntries.join(', ')}`); + throw new Error( + `No files found matching prefix '${pathPrefix}' in archive. ` + + `Archive has ${allEntries.length} entries total.`, + ); + } + + logger.info(`Found ${matchingEntries.length} files to extract`); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const files: string[] = []; + for (const entry of matchingEntries) { + const normalizedEntry = entry.replace(/\\/g, '/'); + const relativePath = normalizedEntry.slice(normalized.length); + + const destPath = path.join(outputDir, ...relativePath.split('/')); + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + fs.writeFileSync(destPath, Buffer.from(unzipped[entry])); + files.push(destPath); + } + + logger.info(`Extracted ${files.length} files to ${outputDir}`); + return { outputDir: path.resolve(outputDir), files, fileCount: files.length }; +} + +/** + * Normalize a path prefix: forward slashes, ensure trailing slash. + */ +function normalizePrefixPath(prefix: string): string { + let p = prefix.replace(/\\/g, '/'); + if (p.length > 0 && !p.endsWith('/')) { + p += '/'; + } + return p; +} + +/** + * Simple glob matching for filename patterns. + * Supports: *.ext, prefix*, *suffix, exact match. + * For v1, this covers the common cases without pulling in a dependency. + */ +export function matchesGlob(filename: string, pattern: string): boolean { + if (pattern === '*') return true; + + // Convert glob to regex: escape special chars, replace * with .* + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + return new RegExp(`^${escaped}$`, 'i').test(filename); +} diff --git a/tests/devtools/decompile-command.test.ts b/tests/devtools/decompile-command.test.ts new file mode 100644 index 0000000..5a6aee5 --- /dev/null +++ b/tests/devtools/decompile-command.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DEFAULT_ASSEMBLIES } from '../../src/devtools/decompile-command'; + +describe('DEFAULT_ASSEMBLIES', () => { + it('contains the 8 known BC DevTools assemblies', () => { + expect(DEFAULT_ASSEMBLIES).toHaveLength(8); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeAnalysis'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.AL.Common'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.Analyzers.Common'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.AppSourceCop'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeCop'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.PerTenantExtensionCop'); + expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.UICop'); + }); + + it('does not contain .dll extensions in assembly names', () => { + for (const assembly of DEFAULT_ASSEMBLIES) { + expect(assembly).not.toMatch(/\.dll$/); + } + }); +}); + +describe('executeDecompile', () => { + it('throws when input directory does not exist', async () => { + const { executeDecompile } = await import('../../src/devtools/decompile-command'); + await expect( + executeDecompile({ + inputDir: '/nonexistent/path', + outputDir: '/tmp/out', + assemblies: DEFAULT_ASSEMBLIES, + keepTool: false, + }), + ).rejects.toThrow('Input directory not found'); + }); +}); diff --git a/tests/devtools/extract-command.test.ts b/tests/devtools/extract-command.test.ts new file mode 100644 index 0000000..fa8fdee --- /dev/null +++ b/tests/devtools/extract-command.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies to avoid network calls +vi.mock('../../src/detectors/nuget-devtools', () => ({ + resolveDevToolsVersion: vi.fn(), +})); + +vi.mock('../../src/detectors/marketplace', () => ({ + resolveExtensionVersion: vi.fn(), +})); + +vi.mock('../../src/http-client', () => ({ + httpsGetBuffer: vi.fn(), +})); + +vi.mock('../../src/devtools/package-extractor', () => ({ + extractFromBuffer: vi.fn(), +})); + +import { resolveDevToolsVersion } from '../../src/detectors/nuget-devtools'; +import { resolveExtensionVersion } from '../../src/detectors/marketplace'; +import { httpsGetBuffer } from '../../src/http-client'; +import { extractFromBuffer } from '../../src/devtools/package-extractor'; +import { executeDevToolsExtract } from '../../src/devtools/extract-command'; + +const mockResolveDevTools = resolveDevToolsVersion as ReturnType; +const mockResolveExtension = resolveExtensionVersion as ReturnType; +const mockHttpsGet = httpsGetBuffer as ReturnType; +const mockExtract = extractFromBuffer as ReturnType; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('executeDevToolsExtract', () => { + describe('nuget source', () => { + it('throws when tfm is not provided', async () => { + await expect( + executeDevToolsExtract({ + source: 'nuget', + version: 'latest', + outputDir: '/tmp/out', + }), + ).rejects.toThrow('--tfm is required'); + }); + + it('downloads and extracts NuGet package with correct prefix', async () => { + mockResolveDevTools.mockResolvedValue('26.1.30873'); + mockHttpsGet.mockResolvedValue(Buffer.from('fake-zip')); + mockExtract.mockReturnValue({ + outputDir: '/tmp/out', + files: ['/tmp/out/foo.dll'], + fileCount: 1, + }); + + const result = await executeDevToolsExtract({ + source: 'nuget', + version: 'latest', + tfm: 'net8.0', + outputDir: '/tmp/out', + }); + + expect(mockResolveDevTools).toHaveBeenCalledWith('latest', expect.anything()); + expect(mockHttpsGet).toHaveBeenCalledWith( + expect.stringContaining('microsoft.dynamics.businesscentral.development.tools'), + expect.any(String), + ); + expect(mockExtract).toHaveBeenCalledWith( + expect.objectContaining({ + pathPrefix: 'tools/net8.0/any', + outputDir: '/tmp/out', + }), + ); + expect(result.source).toBe('nuget'); + expect(result.version).toBe('26.1.30873'); + expect(result.tfm).toBe('net8.0'); + }); + + it('passes include pattern through to extractor', async () => { + mockResolveDevTools.mockResolvedValue('26.1.30873'); + mockHttpsGet.mockResolvedValue(Buffer.from('fake-zip')); + mockExtract.mockReturnValue({ + outputDir: '/tmp/out', + files: [], + fileCount: 0, + }); + + await executeDevToolsExtract({ + source: 'nuget', + version: 'latest', + tfm: 'net8.0', + outputDir: '/tmp/out', + includePattern: '*.dll', + }); + + expect(mockExtract).toHaveBeenCalledWith( + expect.objectContaining({ + includePattern: '*.dll', + }), + ); + }); + }); + + describe('vsix source', () => { + it('downloads and extracts VSIX with correct prefix', async () => { + mockResolveExtension.mockResolvedValue({ + version: '14.0.1234', + vsixUrl: 'https://marketplace.example.com/vsix', + isPreRelease: true, + }); + mockHttpsGet.mockResolvedValue(Buffer.from('fake-vsix')); + mockExtract.mockReturnValue({ + outputDir: '/tmp/out', + files: ['/tmp/out/analyzer.dll'], + fileCount: 1, + }); + + const result = await executeDevToolsExtract({ + source: 'vsix', + version: 'prerelease', + outputDir: '/tmp/out', + }); + + expect(mockResolveExtension).toHaveBeenCalledWith('prerelease', expect.anything()); + expect(mockExtract).toHaveBeenCalledWith( + expect.objectContaining({ + pathPrefix: 'extension/bin/Analyzers', + }), + ); + expect(result.source).toBe('vsix'); + expect(result.version).toBe('14.0.1234'); + }); + + it('defaults to prerelease channel when no version specified', async () => { + mockResolveExtension.mockResolvedValue({ + version: '14.0.1234', + vsixUrl: 'https://example.com/vsix', + isPreRelease: true, + }); + mockHttpsGet.mockResolvedValue(Buffer.from('fake')); + mockExtract.mockReturnValue({ outputDir: '/tmp/out', files: [], fileCount: 0 }); + + await executeDevToolsExtract({ + source: 'vsix', + version: '', + outputDir: '/tmp/out', + }); + + expect(mockResolveExtension).toHaveBeenCalledWith('prerelease', expect.anything()); + }); + }); +}); diff --git a/tests/devtools/package-extractor.test.ts b/tests/devtools/package-extractor.test.ts new file mode 100644 index 0000000..66a8eb1 --- /dev/null +++ b/tests/devtools/package-extractor.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { zipSync } from 'fflate'; +import { extractFromBuffer, matchesGlob } from '../../src/devtools/package-extractor'; + +function createTestZip(entries: Record): Buffer { + const files: Record = {}; + for (const [name, content] of Object.entries(entries)) { + files[name] = typeof content === 'string' + ? new TextEncoder().encode(content) + : content; + } + return Buffer.from(zipSync(files)); +} + +describe('extractFromBuffer', () => { + let tmpDir: string; + + function makeTmpDir(): string { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alcops-test-')); + return tmpDir; + } + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('extracts files matching a path prefix', () => { + const zip = createTestZip({ + 'tools/net8.0/any/foo.dll': 'foo-content', + 'tools/net8.0/any/bar.dll': 'bar-content', + 'tools/net10.0/any/foo.dll': 'foo-net10', + 'other/file.txt': 'unrelated', + }); + const outDir = makeTmpDir(); + + const result = extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'tools/net8.0/any', + outputDir: outDir, + }); + + expect(result.fileCount).toBe(2); + expect(fs.readFileSync(path.join(outDir, 'foo.dll'), 'utf-8')).toBe('foo-content'); + expect(fs.readFileSync(path.join(outDir, 'bar.dll'), 'utf-8')).toBe('bar-content'); + }); + + it('preserves subdirectory structure relative to prefix', () => { + const zip = createTestZip({ + 'extension/bin/Analyzers/main.dll': 'main', + 'extension/bin/Analyzers/sub/nested.dll': 'nested', + }); + const outDir = makeTmpDir(); + + const result = extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'extension/bin/Analyzers', + outputDir: outDir, + }); + + expect(result.fileCount).toBe(2); + expect(fs.existsSync(path.join(outDir, 'sub', 'nested.dll'))).toBe(true); + }); + + it('applies include glob filter', () => { + const zip = createTestZip({ + 'tools/net8.0/any/foo.dll': 'dll', + 'tools/net8.0/any/foo.pdb': 'pdb', + 'tools/net8.0/any/foo.json': 'json', + }); + const outDir = makeTmpDir(); + + const result = extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'tools/net8.0/any', + outputDir: outDir, + includePattern: '*.dll', + }); + + expect(result.fileCount).toBe(1); + expect(fs.existsSync(path.join(outDir, 'foo.dll'))).toBe(true); + expect(fs.existsSync(path.join(outDir, 'foo.pdb'))).toBe(false); + }); + + it('throws when no files match the prefix', () => { + const zip = createTestZip({ + 'other/file.txt': 'content', + }); + const outDir = makeTmpDir(); + + expect(() => + extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'tools/net8.0/any', + outputDir: outDir, + }), + ).toThrow(/No files found matching prefix/); + }); + + it('handles prefix with trailing slash', () => { + const zip = createTestZip({ + 'tools/net8.0/any/foo.dll': 'content', + }); + const outDir = makeTmpDir(); + + const result = extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'tools/net8.0/any/', + outputDir: outDir, + }); + + expect(result.fileCount).toBe(1); + }); + + it('creates output directory if it does not exist', () => { + const zip = createTestZip({ + 'prefix/file.txt': 'content', + }); + const outDir = path.join(makeTmpDir(), 'nested', 'output'); + + const result = extractFromBuffer({ + zipBuffer: zip, + pathPrefix: 'prefix', + outputDir: outDir, + }); + + expect(result.fileCount).toBe(1); + expect(fs.existsSync(path.join(outDir, 'file.txt'))).toBe(true); + }); +}); + +describe('matchesGlob', () => { + it('matches wildcard extension pattern', () => { + expect(matchesGlob('foo.dll', '*.dll')).toBe(true); + expect(matchesGlob('foo.pdb', '*.dll')).toBe(false); + }); + + it('matches star-only pattern', () => { + expect(matchesGlob('anything', '*')).toBe(true); + }); + + it('matches prefix pattern', () => { + expect(matchesGlob('Microsoft.Dynamics.Nav.dll', 'Microsoft.*')).toBe(true); + expect(matchesGlob('Other.dll', 'Microsoft.*')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(matchesGlob('FOO.DLL', '*.dll')).toBe(true); + }); + + it('matches exact filename', () => { + expect(matchesGlob('foo.dll', 'foo.dll')).toBe(true); + expect(matchesGlob('bar.dll', 'foo.dll')).toBe(false); + }); + + it('supports question mark for single character', () => { + expect(matchesGlob('foo.dll', 'fo?.dll')).toBe(true); + expect(matchesGlob('fooo.dll', 'fo?.dll')).toBe(false); + }); +});