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); + }); +});