diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ca7bc..11c603f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the ALCops extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - 2026-09-03 + +### Added +- "ALCops: Copy Version Information" command — copies extension, analyzers, AL Language, VS Code, and OS version details to the clipboard for issue reports +- "ALCops" output channel with extension/analyzers version banner on activation and logging of install/update activity +- Status bar tooltip now shows the extension, analyzers (with channel), and AL Language versions + ## [1.4.0] - 2026-08-20 ### Fixed diff --git a/package.json b/package.json index 7b323ef..2f3cb48 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,10 @@ { "command": "alcops.selectCodeAnalyzers", "title": "ALCops: Select Code Analyzers" + }, + { + "command": "alcops.copyVersionInfo", + "title": "ALCops: Copy Version Information" } ], "jsonValidation": [ diff --git a/src/al-extension-handler.ts b/src/al-extension-handler.ts index 178679d..7340305 100644 --- a/src/al-extension-handler.ts +++ b/src/al-extension-handler.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { checkDirectoryForLockedFiles } from './file-lock-handler.js'; import { resolveAnalyzersDir } from './analyzers-layout.js'; +import { log } from './logger.js'; const AL_EXTENSION_ID = 'ms-dynamics-smb.al'; @@ -35,7 +36,7 @@ function checkALExtensionStatus(analyzerPath: string): ALExtensionStatus { const alExtension = getALExtension(); isRunning = alExtension?.isActive ?? false; } catch (error) { - console.warn('Error checking AL extension status:', error); + log.warn('Error checking AL extension status:', error); } const lockedFiles = checkDirectoryForLockedFiles(analyzerPath).lockedFiles; const hasLocks = lockedFiles.length > 0; diff --git a/src/auto-updater.ts b/src/auto-updater.ts index d04bbd3..9ca25b1 100644 --- a/src/auto-updater.ts +++ b/src/auto-updater.ts @@ -4,6 +4,7 @@ import { queryLatestVersion, downloadALCopsAnalyzers } from './downloader.js'; import { getPendingUpdate } from './manifest-manager.js'; import { getAnalyzersPath, getALExtension } from './al-extension-handler.js'; import { formatError, showTimedMessage } from './utils.js'; +import { log } from './logger.js'; export class AutoUpdater { private readonly _onDidInstallAnalyzers = new vscode.EventEmitter(); @@ -27,7 +28,7 @@ export class AutoUpdater { await this.performUpdateCheck(); } catch (error) { - console.error('Error checking for ALCops updates:', error); + log.error('Error checking for ALCops updates:', error); } } @@ -123,18 +124,18 @@ export class AutoUpdater { private async installVersion(version: string | null, reason: string): Promise { const targetVersion = version ?? await queryLatestVersion(this.getVersionChannel()); if (!targetVersion) { - console.error(`Could not determine version to install (${reason})`); + log.error(`Could not determine version to install (${reason})`); return false; } - console.log(`Installing ALCops v${targetVersion} (${reason})...`); + log.info(`Installing ALCops v${targetVersion} (${reason})...`); try { await downloadALCopsAnalyzers(targetVersion); this._onDidInstallAnalyzers.fire(targetVersion); showTimedMessage(`ALCops v${targetVersion} installed successfully.`); return true; } catch (error) { - console.error(`Failed to install ALCops v${targetVersion}:`, error); + log.error(`Failed to install ALCops v${targetVersion}:`, error); vscode.window.showErrorMessage(`Failed to install ALCops: ${formatError(error)}`); return false; } @@ -146,7 +147,7 @@ export class AutoUpdater { private async performUpdateCheck(): Promise { const latestVersion = await queryLatestVersion(this.getVersionChannel()); if (!latestVersion) { - console.log('Could not determine latest ALCops version'); + log.info('Could not determine latest ALCops version'); return; } @@ -203,7 +204,7 @@ export class AutoUpdater { return false; } - console.log(`Found pending ALCops installation for v${pendingVersion}. Attempting installation...`); + log.info(`Found pending ALCops installation for v${pendingVersion}. Attempting installation...`); return this.installVersion(pendingVersion, 'pending deferred installation'); } @@ -213,7 +214,7 @@ export class AutoUpdater { async performStartupChecks(): Promise { try { if (!getALExtension()) { - console.log('AL extension is not installed. Skipping ALCops startup checks.'); + log.info('AL extension is not installed. Skipping ALCops startup checks.'); return; } @@ -227,7 +228,7 @@ export class AutoUpdater { await this.checkAndNotifyUpdates(); } catch (error) { - console.error('Error during startup checks:', error); + log.error('Error during startup checks:', error); } } diff --git a/src/downloader.ts b/src/downloader.ts index 5de3539..1bdd866 100644 --- a/src/downloader.ts +++ b/src/downloader.ts @@ -15,6 +15,7 @@ import { getALExtension, promptUserForLockedFiles } from './al-extension-handler import { resolveAnalyzersDir, getAnalyzersDirCandidates, CODE_ANALYSIS_DLL } from './analyzers-layout.js'; import { launchNewVSCodeWindow } from './vscode-launcher.js'; import { formatError, showTimedMessage } from './utils.js'; +import { log } from './logger.js'; const PACKAGE_NAME = 'ALCops.Analyzers'; @@ -113,7 +114,7 @@ export async function queryLatestVersion(channel: 'stable' | 'beta' | 'alpha'): return filtered.sort((a, b) => compare(a.version, b.version)).at(-1)!.version; } catch (error) { - console.error('Error querying NuGet for latest version:', error); + log.error('Error querying NuGet for latest version:', error); return null; } } @@ -306,7 +307,7 @@ async function downloadALCopsAnalyzersInternal(version: string): Promise { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (err) { - console.warn(`Failed to clean up temp directory: ${err}`); + log.warn(`Failed to clean up temp directory: ${err}`); } } } @@ -316,7 +317,7 @@ async function handleLockedFiles(targetPath: string, version: string): Promise<' const lockCheck = checkDirectoryForLockedFiles(targetPath); if (!lockCheck.isLocked) { return 'proceed'; } - console.warn(`Locked files detected: ${lockCheck.lockedFiles.join(', ')}`); + log.warn(`Locked files detected: ${lockCheck.lockedFiles.join(', ')}`); const userChoice = await promptUserForLockedFiles(targetPath, version); if (userChoice === 'cancel') { diff --git a/src/extension.ts b/src/extension.ts index 6d2ccfb..6f729c4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,19 +4,27 @@ import * as vscode from 'vscode'; import { VersionManager } from './version-manager.js'; import { AutoUpdater } from './auto-updater.js'; import { StatusBarManager } from './status-bar-manager.js'; +import { initLogger, log } from './logger.js'; +import { gatherVersionInfo, formatClipboardText, formatVersionBanner } from './version-info.js'; +import { showTimedMessage } from './utils.js'; // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export async function activate(context: vscode.ExtensionContext) { - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "ALCops" is now active!'); + // Create the "ALCops" output channel before anything logs + initLogger(context); + log.info(`ALCops is now active. ${formatVersionBanner(gatherVersionInfo())}`); // Initialize version manager and auto updater first; StatusBarManager subscribes to its event const versionManager = new VersionManager(context); const autoUpdater = new AutoUpdater(versionManager); + // Log the resulting versions after every successful installation + const installLogDisposable = autoUpdater.onDidInstallAnalyzers((version) => { + log.info(`Installed ALCops.Analyzers v${version}. ${formatVersionBanner(gatherVersionInfo())}`); + }); + // Initialize status bar manager and wire up the installation event const statusBarManager = new StatusBarManager(context, autoUpdater.onDidInstallAnalyzers); @@ -28,7 +36,7 @@ export async function activate(context: vscode.ExtensionContext) { try { await autoUpdater.checkUpdatesManually(); } catch (error) { - console.error('Check updates command failed:', error); + log.error('Check updates command failed:', error); } }); @@ -37,15 +45,27 @@ export async function activate(context: vscode.ExtensionContext) { try { await autoUpdater.installLatestVersion(); } catch (error) { - console.error('Install update command failed:', error); + log.error('Install update command failed:', error); + } + }); + + // Register the copy version information command + const copyVersionInfoDisposable = vscode.commands.registerCommand('alcops.copyVersionInfo', async () => { + try { + await vscode.env.clipboard.writeText(formatClipboardText(gatherVersionInfo())); + showTimedMessage('ALCops version information copied to clipboard.'); + } catch (error) { + log.error('Copy version information command failed:', error); } }); context.subscriptions.push( statusBarManager, autoUpdater, + installLogDisposable, checkUpdatesDisposable, - installDisposable + installDisposable, + copyVersionInfoDisposable ); } diff --git a/src/file-staging.ts b/src/file-staging.ts index 7f8a336..502d89c 100644 --- a/src/file-staging.ts +++ b/src/file-staging.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { formatError } from './utils.js'; +import { log } from './logger.js'; /** * Result of staging and replacing files @@ -63,7 +64,7 @@ export function stageAndReplaceFiles( replacedFiles.push(file); } catch (error) { failedFiles.push(file); - console.error(`Failed to replace file ${file}:`, error); + log.error(`Failed to replace file ${file}:`, error); // Don't continue - we want all-or-nothing break; } @@ -80,7 +81,7 @@ export function stageAndReplaceFiles( } // Step 5: Rollback on partial failure - console.warn(`Partial failure detected (${failedFiles.length}/${sourceFiles.length}). Rolling back...`); + log.warn(`Partial failure detected (${failedFiles.length}/${sourceFiles.length}). Rolling back...`); rollbackFiles(backupDir, targetDir); return { @@ -118,14 +119,14 @@ function rollbackFiles(backupDir: string, targetDir: string): void { try { fs.copyFileSync(backupFile, targetFile); } catch (error) { - console.error(`Failed to rollback file ${file}:`, error); + log.error(`Failed to rollback file ${file}:`, error); } } // Clean up backup directory fs.rmSync(backupDir, { recursive: true, force: true }); } catch (error) { - console.error('Failed to complete rollback:', error); + log.error('Failed to complete rollback:', error); } } @@ -145,11 +146,11 @@ export function cleanupOldBackups(targetDir: string, maxAge: number = 24 * 60 * if (age > maxAge) { fs.rmSync(backupPath, { recursive: true, force: true }); - console.log(`Cleaned up old backup: ${file}`); + log.info(`Cleaned up old backup: ${file}`); } } } } catch (error) { - console.warn('Failed to cleanup old backups:', error); + log.warn('Failed to cleanup old backups:', error); } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..9567638 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,56 @@ +import * as vscode from 'vscode'; + +let outputChannel: vscode.LogOutputChannel | undefined; + +/** + * Create the "ALCops" output channel and register it for disposal. + * Call this first thing during activation, before anything logs. + */ +export function initLogger(context: vscode.ExtensionContext): vscode.LogOutputChannel { + if (!outputChannel) { + outputChannel = vscode.window.createOutputChannel('ALCops', { log: true }); + context.subscriptions.push(outputChannel); + } + return outputChannel; +} + +/** + * Logging facade used across the extension. + * + * Writes to the "ALCops" output channel once {@link initLogger} has run, and + * falls back to the console otherwise. The fallback keeps pure-Node modules + * importable in unit tests, where no VS Code window exists. + */ +export const log = { + info(message: string, ...args: unknown[]): void { + if (outputChannel) { + outputChannel.info(message, ...args); + } else { + console.log(message, ...args); + } + }, + + warn(message: string, ...args: unknown[]): void { + if (outputChannel) { + outputChannel.warn(message, ...args); + } else { + console.warn(message, ...args); + } + }, + + error(message: string | Error, ...args: unknown[]): void { + if (outputChannel) { + outputChannel.error(message, ...args); + } else { + console.error(message, ...args); + } + }, + + debug(message: string, ...args: unknown[]): void { + if (outputChannel) { + outputChannel.debug(message, ...args); + } else { + console.debug(message, ...args); + } + }, +}; diff --git a/src/manifest-manager.ts b/src/manifest-manager.ts index ec6a6d2..620a344 100644 --- a/src/manifest-manager.ts +++ b/src/manifest-manager.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { formatError } from './utils.js'; +import { log } from './logger.js'; /** * Represents the manifest metadata for ALCops analyzer installation @@ -46,7 +47,7 @@ export function readManifest(targetPath: string): ALCopsManifest | null { const data = fs.readFileSync(manifestPath, 'utf-8'); return JSON.parse(data) as ALCopsManifest; } catch (error) { - console.warn(`Failed to read manifest file: ${formatError(error)}`); + log.warn(`Failed to read manifest file: ${formatError(error)}`); return null; } } @@ -98,9 +99,9 @@ export function markAsPendingUpdate( } writeManifest(targetPath, manifest); - console.log(`Marked ALCops v${pendingVersion} as pending for next startup`); + log.info(`Marked ALCops v${pendingVersion} as pending for next startup`); } catch (error) { - console.warn(`Failed to mark pending update: ${formatError(error)}`); + log.warn(`Failed to mark pending update: ${formatError(error)}`); } } @@ -110,12 +111,12 @@ export function markAsPendingUpdate( export function getPendingUpdate(targetPath: string): string | null { try { const manifest = readManifest(targetPath); - console.log(`getPendingUpdate: manifest exists=${!!manifest}, pendingUpdate=${manifest?.pendingUpdate}, version=${manifest?.pendingVersion}`); + log.info(`getPendingUpdate: manifest exists=${!!manifest}, pendingUpdate=${manifest?.pendingUpdate}, version=${manifest?.pendingVersion}`); if (manifest?.pendingUpdate && manifest?.pendingVersion) { return manifest.pendingVersion; } } catch (error) { - console.warn(`Failed to get pending update: ${formatError(error)}`); + log.warn(`Failed to get pending update: ${formatError(error)}`); } return null; } @@ -132,6 +133,6 @@ export function clearPendingUpdate(targetPath: string): void { writeManifest(targetPath, manifest); } } catch (error) { - console.warn(`Failed to clear pending update: ${formatError(error)}`); + log.warn(`Failed to clear pending update: ${formatError(error)}`); } } diff --git a/src/status-bar-manager.ts b/src/status-bar-manager.ts index 2a0f978..8333c81 100644 --- a/src/status-bar-manager.ts +++ b/src/status-bar-manager.ts @@ -2,11 +2,15 @@ import * as vscode from 'vscode'; import { CodeAnalyzersManager } from './code-analyzers-manager.js'; import { getAnalyzersPath } from './al-extension-handler.js'; import { showTimedMessage } from './utils.js'; +import { log } from './logger.js'; +import { gatherVersionInfo, formatTooltipMarkdown, VersionInfo } from './version-info.js'; export class StatusBarManager { private statusBarItem: vscode.StatusBarItem; private disposables: vscode.Disposable[] = []; private codeAnalyzersManager: CodeAnalyzersManager | null = null; + /** Cached so the tooltip does not re-read the manifest on every editor switch. */ + private versionInfo: VersionInfo | null = null; constructor(context: vscode.ExtensionContext, onDidInstallAnalyzers: vscode.Event) { // Create status bar item on the left side, before the language mode @@ -39,7 +43,10 @@ export class StatusBarManager { // Listen for configuration changes const configChangeDisposable = vscode.workspace.onDidChangeConfiguration( (event) => { - if (event.affectsConfiguration('al.codeAnalyzers')) { + if (event.affectsConfiguration('alcops.versionChannel')) { + this.versionInfo = null; + this.updateStatusBar(); + } else if (event.affectsConfiguration('al.codeAnalyzers')) { this.updateStatusBar(); } } @@ -48,6 +55,7 @@ export class StatusBarManager { // Refresh the analyzer list whenever a new installation completes const installDisposable = onDidInstallAnalyzers(() => { this.codeAnalyzersManager?.refresh(); + this.versionInfo = null; this.updateStatusBar(); }); @@ -66,10 +74,21 @@ export class StatusBarManager { this.statusBarItem.text = `ALCops: ${activeCodeAnalyzersCount}`; this.statusBarItem.command = 'alcops.selectCodeAnalyzers'; - this.statusBarItem.tooltip = `Click to select Code Analyzers (${activeCodeAnalyzersCount} active)`; + this.statusBarItem.tooltip = new vscode.MarkdownString( + formatTooltipMarkdown(this.getVersionInfo(), activeCodeAnalyzersCount) + ); this.statusBarItem.show(); } + /** + * Version information for the tooltip, read once and cached until an + * installation completes or the version channel changes. + */ + private getVersionInfo(): VersionInfo { + this.versionInfo ??= gatherVersionInfo(); + return this.versionInfo; + } + /** * Get the current URI for configuration (prioritizes active editor) */ @@ -199,7 +218,7 @@ export class StatusBarManager { showTimedMessage(message); } catch (error) { vscode.window.showErrorMessage(`Failed to update Code Analyzers: ${error}`); - console.error('Error updating Code Analyzers:', error); + log.error('Error updating Code Analyzers:', error); } this.updateStatusBar(); diff --git a/src/version-info.ts b/src/version-info.ts new file mode 100644 index 0000000..9b84da2 --- /dev/null +++ b/src/version-info.ts @@ -0,0 +1,136 @@ +import * as os from 'os'; +import * as vscode from 'vscode'; +import { readManifest } from './manifest-manager.js'; +import { getALExtension, getAnalyzersPath } from './al-extension-handler.js'; + +const EXTENSION_ID = 'arthurvdv.alcops'; +const NOT_INSTALLED = 'not installed'; + +/** + * Everything needed to answer "which versions am I running?" - shared by the + * status bar tooltip, the copy-to-clipboard command and the log banners. + */ +export interface VersionInfo { + /** Version of this VS Code extension. */ + extensionVersion: string; + /** Installed ALCops.Analyzers version, or null when not installed. */ + analyzersVersion: string | null; + /** Configured `alcops.versionChannel`. */ + channel: string; + /** Version of the AL Language extension, or null when not installed. */ + alExtensionVersion: string | null; + /** Folder the analyzer DLLs live in, or null when it cannot be resolved. */ + analyzersPath: string | null; + /** When the analyzers were downloaded, or null when not installed. */ + downloadedAt: string | null; + /** Target framework the analyzers were installed for, or null when not installed. */ + targetFramework: string | null; +} + +/** + * Host details included in the clipboard block. Passing these in explicitly + * keeps {@link formatClipboardText} pure and unit-testable. + */ +export interface EnvironmentInfo { + vscodeVersion: string; + platform: string; + arch: string; + osType: string; + osRelease: string; +} + +/** + * Collect the current version information (one manifest read). + */ +export function gatherVersionInfo(): VersionInfo { + const extensionVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON?.version ?? 'unknown'; + const channel = vscode.workspace.getConfiguration('alcops').get('versionChannel', 'stable'); + const alExtensionVersion = getALExtension()?.packageJSON?.version ?? null; + const analyzersPath = getAnalyzersPath(); + const manifest = analyzersPath ? readManifest(analyzersPath) : null; + + // A manifest created for a pending update carries the literal 'unknown' + // placeholder; that is not an installed version. + const analyzersVersion = manifest && manifest.alcopsVersion && manifest.alcopsVersion !== 'unknown' + ? manifest.alcopsVersion + : null; + + return { + extensionVersion, + analyzersVersion, + channel, + alExtensionVersion, + analyzersPath, + downloadedAt: analyzersVersion ? manifest?.downloadedAt ?? null : null, + targetFramework: analyzersVersion ? manifest?.targetFramework ?? null : null, + }; +} + +/** + * Collect the host details for the clipboard block. + */ +export function gatherEnvironmentInfo(): EnvironmentInfo { + return { + vscodeVersion: vscode.version, + platform: process.platform, + arch: process.arch, + osType: os.type(), + osRelease: os.release(), + }; +} + +/** + * Markdown shown when hovering the status bar item. + */ +export function formatTooltipMarkdown(info: VersionInfo, activeCount: number): string { + const analyzers = info.analyzersVersion + ? `v${info.analyzersVersion} (${info.channel} channel)` + : NOT_INSTALLED; + const alLanguage = info.alExtensionVersion ? `v${info.alExtensionVersion}` : NOT_INSTALLED; + + // Two trailing spaces force a markdown line break inside the tooltip. + const header = [ + '**ALCops**', + `Extension: v${info.extensionVersion}`, + `Analyzers: ${analyzers}`, + `AL Language: ${alLanguage}`, + ].join(' \n'); + + return `${header}\n\nClick to select Code Analyzers (${activeCount} active)`; +} + +/** + * Ready-to-paste diagnostics block for issue reports. + */ +export function formatClipboardText(info: VersionInfo, env: EnvironmentInfo = gatherEnvironmentInfo()): string { + const lines = [ + `ALCops extension: ${info.extensionVersion}`, + `ALCops.Analyzers: ${info.analyzersVersion ? `${info.analyzersVersion} (channel: ${info.channel})` : NOT_INSTALLED}`, + `AL Language extension: ${info.alExtensionVersion ?? NOT_INSTALLED}`, + `VS Code: ${env.vscodeVersion}`, + `OS: ${env.platform} ${env.arch} (${env.osType} ${env.osRelease})`, + `Analyzers path: ${info.analyzersPath ?? NOT_INSTALLED}`, + ]; + + if (info.downloadedAt) { + lines.push(`Downloaded at: ${info.downloadedAt}`); + } + if (info.targetFramework) { + lines.push(`Target framework: ${info.targetFramework}`); + } + + return lines.join('\n'); +} + +/** + * Single-line summary written to the output channel on activation and after + * every analyzer installation. + */ +export function formatVersionBanner(info: VersionInfo): string { + const analyzers = info.analyzersVersion + ? `v${info.analyzersVersion} (${info.channel} channel)` + : NOT_INSTALLED; + const alLanguage = info.alExtensionVersion ? `v${info.alExtensionVersion}` : NOT_INSTALLED; + + return `Extension v${info.extensionVersion} | ALCops.Analyzers ${analyzers} | AL Language ${alLanguage}`; +} diff --git a/src/version-manager.ts b/src/version-manager.ts index b09bc97..7bbef58 100644 --- a/src/version-manager.ts +++ b/src/version-manager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { readManifest } from './manifest-manager.js'; import { getALExtension, getAnalyzersPath } from './al-extension-handler.js'; +import { log } from './logger.js'; const LAST_UPDATE_CHECK_KEY = 'alcops.lastUpdateCheck'; @@ -74,7 +75,7 @@ export class VersionManager { return extractor(manifest) || null; } catch (error) { - console.warn(`Failed to read manifest value: ${error}`); + log.warn(`Failed to read manifest value: ${error}`); return null; } } diff --git a/tests/version-info.test.ts b/tests/version-info.test.ts new file mode 100644 index 0000000..f7a83b8 --- /dev/null +++ b/tests/version-info.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + version: '1.125.0', + extensions: { + getExtension: () => ({ packageJSON: { version: '0.0.0-test' } }), + }, + workspace: { + getConfiguration: () => ({ get: (_key: string, fallback: string) => fallback }), + }, +})); + +import { formatTooltipMarkdown, formatClipboardText, formatVersionBanner } from '../src/version-info.js'; +import type { VersionInfo, EnvironmentInfo } from '../src/version-info.js'; + +const env: EnvironmentInfo = { + vscodeVersion: '1.125.0', + platform: 'win32', + arch: 'x64', + osType: 'Windows_NT', + osRelease: '10.0.26200', +}; + +const installed: VersionInfo = { + extensionVersion: '1.3.3', + analyzersVersion: '0.9.2', + channel: 'stable', + alExtensionVersion: '18.0.123456', + analyzersPath: 'C:\\ext\\al\\bin', + downloadedAt: '2026-09-01T10:00:00.000Z', + targetFramework: 'net8.0', +}; + +const notInstalled: VersionInfo = { + extensionVersion: '1.3.3', + analyzersVersion: null, + channel: 'beta', + alExtensionVersion: '18.0.123456', + analyzersPath: 'C:\\ext\\al\\bin', + downloadedAt: null, + targetFramework: null, +}; + +const alMissing: VersionInfo = { + extensionVersion: '1.3.3', + analyzersVersion: null, + channel: 'stable', + alExtensionVersion: null, + analyzersPath: null, + downloadedAt: null, + targetFramework: null, +}; + +describe('formatTooltipMarkdown', () => { + it('lists all versions when everything is installed', () => { + const tooltip = formatTooltipMarkdown(installed, 3); + + expect(tooltip).toContain('**ALCops**'); + expect(tooltip).toContain('Extension: v1.3.3'); + expect(tooltip).toContain('Analyzers: v0.9.2 (stable channel)'); + expect(tooltip).toContain('AL Language: v18.0.123456'); + expect(tooltip).toContain('Click to select Code Analyzers (3 active)'); + }); + + it('uses markdown line breaks between the version lines', () => { + expect(formatTooltipMarkdown(installed, 1)).toContain('**ALCops** \nExtension: v1.3.3'); + }); + + it('reports the configured channel', () => { + expect(formatTooltipMarkdown({ ...installed, channel: 'alpha' }, 0)).toContain('(alpha channel)'); + }); + + it('shows "not installed" when the analyzers are missing', () => { + const tooltip = formatTooltipMarkdown(notInstalled, 0); + + expect(tooltip).toContain('Analyzers: not installed'); + expect(tooltip).toContain('AL Language: v18.0.123456'); + expect(tooltip).toContain('Click to select Code Analyzers (0 active)'); + }); + + it('shows "not installed" when the AL extension is missing', () => { + const tooltip = formatTooltipMarkdown(alMissing, 0); + + expect(tooltip).toContain('Analyzers: not installed'); + expect(tooltip).toContain('AL Language: not installed'); + }); +}); + +describe('formatClipboardText', () => { + it('includes every detail when the analyzers are installed', () => { + expect(formatClipboardText(installed, env)).toBe([ + 'ALCops extension: 1.3.3', + 'ALCops.Analyzers: 0.9.2 (channel: stable)', + 'AL Language extension: 18.0.123456', + 'VS Code: 1.125.0', + 'OS: win32 x64 (Windows_NT 10.0.26200)', + 'Analyzers path: C:\\ext\\al\\bin', + 'Downloaded at: 2026-09-01T10:00:00.000Z', + 'Target framework: net8.0', + ].join('\n')); + }); + + it('omits the download and framework lines when not installed', () => { + const text = formatClipboardText(notInstalled, env); + + expect(text).toContain('ALCops.Analyzers: not installed'); + expect(text).not.toContain('Downloaded at:'); + expect(text).not.toContain('Target framework:'); + }); + + it('reports the missing AL extension and analyzers path', () => { + const text = formatClipboardText(alMissing, env); + + expect(text).toContain('AL Language extension: not installed'); + expect(text).toContain('Analyzers path: not installed'); + }); + + it('falls back to the live environment when none is passed', () => { + expect(formatClipboardText(installed)).toContain('VS Code: 1.125.0'); + }); +}); + +describe('formatVersionBanner', () => { + it('summarizes all three versions on one line', () => { + expect(formatVersionBanner(installed)).toBe( + 'Extension v1.3.3 | ALCops.Analyzers v0.9.2 (stable channel) | AL Language v18.0.123456' + ); + }); + + it('reports missing components', () => { + expect(formatVersionBanner(alMissing)).toBe( + 'Extension v1.3.3 | ALCops.Analyzers not installed | AL Language not installed' + ); + }); +});