From 7b8f30b9b2d224676db325b1954bcb45b71b2c41 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 3 Sep 2026 16:11:13 +0200 Subject: [PATCH] feat: surface extension and analyzer versions for issue reports Users filing issues report the VS Code extension version when the version of the downloaded ALCops.Analyzers package is what actually matters. Make both versions easy to find and copy: - Add "ALCops: Copy Version Information" command that copies a ready-to-paste block (extension, analyzers + channel, AL Language, VS Code, OS, analyzers path) to the clipboard - Add "ALCops" output channel (LogOutputChannel) with a version banner on activation and after every install/update; route existing console.* logging through it - Enrich the status bar tooltip with extension, analyzers (with channel), and AL Language versions; text and click behavior unchanged Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ package.json | 4 ++ src/al-extension-handler.ts | 3 +- src/auto-updater.ts | 17 ++--- src/downloader.ts | 7 +- src/extension.ts | 32 +++++++-- src/file-staging.ts | 13 ++-- src/logger.ts | 56 +++++++++++++++ src/manifest-manager.ts | 13 ++-- src/status-bar-manager.ts | 25 ++++++- src/version-info.ts | 136 ++++++++++++++++++++++++++++++++++++ src/version-manager.ts | 3 +- tests/version-info.test.ts | 135 +++++++++++++++++++++++++++++++++++ 13 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 src/logger.ts create mode 100644 src/version-info.ts create mode 100644 tests/version-info.test.ts 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' + ); + }); +});