diff --git a/.github/coverage-mine.ps1 b/.github/coverage-mine.ps1 index 0ca73659..38106fa4 100644 --- a/.github/coverage-mine.ps1 +++ b/.github/coverage-mine.ps1 @@ -46,9 +46,8 @@ $totalLines = 0 $coveredLines = 0 foreach ($package in $coverage.coverage.packages.package) { - $packageName = $package.name foreach ($class in $package.classes.class) { - $filename = Join-Path $packageName $class.filename + $filename = ($class.filename -replace '/', '\') if ($changedFilesSet.ContainsKey($filename)) { $classLines = 0 diff --git a/.github/cwf.ps1 b/.github/cwf.ps1 index fcbbb668..2bd929bf 100644 --- a/.github/cwf.ps1 +++ b/.github/cwf.ps1 @@ -1,3 +1,43 @@ +param( + [switch]$Local +) + +$localCwf = Join-Path (Get-Location) "..\cs-webview" +$localPackage = Join-Path $localCwf "package.json" + +if ($Local) { + if (-not (Test-Path $localPackage)) { + Write-Error "Local CWF checkout not found at $localCwf" + exit 1 + } + + Write-Host "Building CWF locally from $localCwf..." + Push-Location $localCwf + try { + & npm run build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + finally { + Pop-Location + } + + $assetsPath = Join-Path $localCwf "build\assets" + if (-not (Test-Path $assetsPath)) { + Write-Error "Local CWF build did not produce build\assets." + exit 1 + } + + if (Test-Path cs-cwf) { Remove-Item cs-cwf -Recurse -Force } + New-Item -ItemType Directory -Path cs-cwf | Out-Null + Copy-Item (Join-Path $assetsPath "*") cs-cwf + $webDir = "Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent" + Copy-Item cs-cwf/index.css $webDir -Force + Copy-Item cs-cwf/index.js $webDir -Force + if (Test-Path cs-cwf.zip) { Remove-Item cs-cwf.zip -Force } + Compress-Archive -Path "cs-cwf\*" -DestinationPath cs-cwf.zip + exit 0 +} + $Token = $env:CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN $headers = @{ diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index b4a34e0b..3412493d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -107,20 +107,3 @@ jobs: comment-on-alert: true fail-on-alert: true alert-comment-cc-users: '@martinsafsten-codescene' - - - name: Run CachingCodeReviewer benchmarks - shell: pwsh - working-directory: Codescene.VSExtension.VS2022 - run: dotnet run --project Codescene.VSExtension.Core.Benchmarks/Codescene.VSExtension.Core.Benchmarks.csproj -c Release --no-build -- --filter "*CachingCodeReviewerBenchmarks*" --exporters json - - - name: Store CachingCodeReviewer benchmark result - uses: benchmark-action/github-action-benchmark@v1 - with: - name: Code Review - CachingCodeReviewer - tool: 'benchmarkdotnet' - output-file-path: Codescene.VSExtension.VS2022/BenchmarkDotNet.Artifacts/results/Codescene.VSExtension.Core.Benchmarks.CachingCodeReviewerBenchmarks-report-full-compressed.json - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: true - comment-on-alert: true - fail-on-alert: true - alert-comment-cc-users: '@martinsafsten-codescene' diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 4a013c39..a49293ed 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -46,7 +46,7 @@ jobs: needs: fetch-docs # env: # Feature flags here. Example: - # FEATURE_INITIAL_GIT_OBSERVER: true + # FEATURE_ACE: true steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs index fe92e78b..e214cfc2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs @@ -1,16 +1,10 @@ // Copyright (c) CodeScene. All rights reserved. -using System.Collections.Concurrent; -using System.Reflection; -using Codescene.VSExtension.Core.Application.Cache.Review; using Codescene.VSExtension.Core.Application.Cli; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Ace; using Codescene.VSExtension.Core.Interfaces.Cli; using Codescene.VSExtension.Core.Interfaces.Extension; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cache.Review; using Codescene.VSExtension.Core.Models.Cli.Refactor; using AutoRefactorConfig = Codescene.VSExtension.Core.Models.WebComponent.Data.AutoRefactorConfig; @@ -22,8 +16,7 @@ internal sealed class BenchmarkEnvironment : IDisposable private readonly ICliSettingsProvider _cliSettingsProvider = new CliSettingsProvider(); private readonly ModelMapper _modelMapper = new ModelMapper(); private readonly FixedSettingsProvider _settingsProvider = new FixedSettingsProvider(); - private readonly IProcessExecutor _processExecutor; - private readonly ICliCommandProvider _commandProvider; + private readonly IIdeServerClient _ideServerClient; private readonly BenchmarkCacheStorageService _cacheStorageService; private bool _disposed; @@ -38,8 +31,7 @@ public BenchmarkEnvironment() System.IO.File.WriteAllText(ExistingFilePath, BenchmarkInputs.CurrentCode); _cacheStorageService = new BenchmarkCacheStorageService(CacheDirectory, RootDirectory); - _commandProvider = new CliCommandProvider(new CliObjectScoreCreator(_logger)); - _processExecutor = CreateProcessExecutor(_cliSettingsProvider, _logger); + _ideServerClient = new IdeServerClient(_cliSettingsProvider, _logger, _settingsProvider); } public string RootDirectory { get; } @@ -52,36 +44,12 @@ public CliExecutor CreateCliExecutor() { return new CliExecutor( _logger, - new BenchmarkCliServices(_commandProvider, _processExecutor, _cacheStorageService), + _ideServerClient, + _cacheStorageService, _settingsProvider, null); } - public CodeReviewer CreateCodeReviewer(IGitService gitService, IPreflightManager? preflightManager = null) - { - return new CodeReviewer( - _logger, - _modelMapper, - CreateCliExecutor(), - null, - gitService, - null, - preflightManager); - } - - public CachingCodeReviewer CreateCachingCodeReviewer(IGitService gitService, IPreflightManager? preflightManager = null) - { - return new CachingCodeReviewer( - CreateCodeReviewer(gitService, preflightManager), - new ReviewCacheService(new ConcurrentDictionary()), - new BaselineReviewCacheService(new ConcurrentDictionary()), - new DeltaCacheService(new ConcurrentDictionary()), - _logger, - gitService, - null, - null); - } - public void Dispose() { if (_disposed) @@ -90,6 +58,7 @@ public void Dispose() } _disposed = true; + _ideServerClient.Dispose(); try { Directory.Delete(RootDirectory, true); @@ -98,20 +67,6 @@ public void Dispose() { } } - - private static IProcessExecutor CreateProcessExecutor(ICliSettingsProvider cliSettingsProvider, ILogger logger) - { - var processExecutorType = typeof(CliSettingsProvider).Assembly.GetType( - "Codescene.VSExtension.Core.Application.Cli.ProcessExecutor", - throwOnError: true); - var processExecutor = (IProcessExecutor?)Activator.CreateInstance( - processExecutorType, - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, - null, - new object[] { cliSettingsProvider, logger }, - null); - return processExecutor!; - } } internal static class BenchmarkInputs @@ -153,22 +108,6 @@ public int Calculate(int a, int b, int c, int d, int e, int f, int g, int h) }"; } -internal sealed class BenchmarkCliServices : ICliServices -{ - public BenchmarkCliServices(ICliCommandProvider commandProvider, IProcessExecutor processExecutor, ICacheStorageService cacheStorage) - { - CommandProvider = commandProvider; - ProcessExecutor = processExecutor; - CacheStorage = cacheStorage; - } - - public ICliCommandProvider CommandProvider { get; } - - public IProcessExecutor ProcessExecutor { get; } - - public ICacheStorageService CacheStorage { get; } -} - internal sealed class BenchmarkCacheStorageService : ICacheStorageService { public BenchmarkCacheStorageService(string cacheDirectory, string workspaceDirectory) @@ -220,53 +159,12 @@ internal sealed class FixedSettingsProvider : ISettingsProvider public bool ShowDebugLogs => false; public string AuthToken => string.Empty; -} -internal sealed class FixedGitService : IGitService -{ - private readonly string _defaultBaselineContent; - private readonly Dictionary _pathContents = new Dictionary(StringComparer.OrdinalIgnoreCase); + public int ServerWorkerThreads => 0; - public FixedGitService(string defaultBaselineContent) - { - _defaultBaselineContent = defaultBaselineContent; - } - - public void SetContent(string path, string content) - { - _pathContents[path] = content; - } - - public string GetBaselineCommit(string repoRootPath) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path) - { - return GetFileContentForCommit(path, string.Empty); - } + public bool EnableAutomaticAnalysis => true; - public string GetFileContentForCommit(string path, string baselineCommit) - { - if (path != null && _pathContents.TryGetValue(path, out var content)) - { - return content; - } - - return _defaultBaselineContent; - } - - public bool IsFileIgnored(string filePath) => false; - - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - return new HashSet(absolutePaths, StringComparer.OrdinalIgnoreCase); - } - - public void Dispose() - { - } + public bool EnableTelemetry => true; } internal sealed class FixedPreflightManager : IPreflightManager diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CachingCodeReviewerBenchmarks.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CachingCodeReviewerBenchmarks.cs deleted file mode 100644 index 4f9e4d1e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CachingCodeReviewerBenchmarks.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using BenchmarkDotNet.Attributes; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cli.Delta; - -namespace Codescene.VSExtension.Core.Benchmarks; - -[MemoryDiagnoser] -[JsonExporterAttribute.FullCompressed] -public class CachingCodeReviewerBenchmarks -{ - private BenchmarkEnvironment _environment = null!; - private FixedGitService _gitService = null!; - private FixedPreflightManager _preflightManager = null!; - private CachingCodeReviewer _coldReviewReviewer = null!; - private CachingCodeReviewer _warmReviewReviewer = null!; - private CachingCodeReviewer _warmDeltaReviewer = null!; - private string _warmReviewPath = null!; - private int _pathCounter; - - [GlobalSetup] - public void Setup() - { - _environment = new BenchmarkEnvironment(); - _gitService = new FixedGitService(BenchmarkInputs.BaselineCode); - - var preflightResponse = _environment.CreateCliExecutor().PreflightAsync(force: true).GetAwaiter().GetResult(); - if (preflightResponse == null) - { - throw new InvalidOperationException("Unable to obtain a preflight response for caching benchmarks."); - } - - _preflightManager = new FixedPreflightManager(preflightResponse); - _coldReviewReviewer = _environment.CreateCachingCodeReviewer(_gitService); - - _warmReviewReviewer = _environment.CreateCachingCodeReviewer(_gitService); - _warmReviewPath = Path.Combine(_environment.RootDirectory, "cached-review.cs"); - _warmReviewReviewer.ReviewAsync(_warmReviewPath, BenchmarkInputs.CurrentCode).GetAwaiter().GetResult(); - - _warmDeltaReviewer = _environment.CreateCachingCodeReviewer(_gitService, _preflightManager); - _warmDeltaReviewer.ReviewWithDeltaAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode).GetAwaiter().GetResult(); - } - - [GlobalCleanup] - public void Cleanup() - { - _environment?.Dispose(); - } - - [Benchmark] - public Task ReviewAsyncCold() - { - return _coldReviewReviewer.ReviewAsync(NextPath("cached-review-miss"), BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task ReviewAsyncWarm() - { - return _warmReviewReviewer.ReviewAsync(_warmReviewPath, BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsyncCold() - { - return _environment.CreateCachingCodeReviewer(_gitService, _preflightManager) - .ReviewWithDeltaAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsyncWarm() - { - return _warmDeltaReviewer.ReviewWithDeltaAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode); - } - - private string NextPath(string prefix) - { - var index = Interlocked.Increment(ref _pathCounter); - return Path.Combine(_environment.RootDirectory, prefix + "-" + index + ".cs"); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CliExecutorBenchmarks.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CliExecutorBenchmarks.cs index 2700b60c..bd6cc046 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CliExecutorBenchmarks.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CliExecutorBenchmarks.cs @@ -14,7 +14,6 @@ public class CliExecutorBenchmarks { private BenchmarkEnvironment _environment = null!; private CliExecutor _cliExecutor = null!; - private ReviewDeltaRequest _deltaRequest = null!; private DeltaResponseModel _deltaResponse = null!; private PreFlightResponseModel _preflightResponse = null!; @@ -24,26 +23,7 @@ public void Setup() _environment = new BenchmarkEnvironment(); _cliExecutor = _environment.CreateCliExecutor(); - var baselineReview = _cliExecutor.ReviewContentAsync(_environment.ExistingFilePath, BenchmarkInputs.BaselineCode).GetAwaiter().GetResult(); - var currentReview = _cliExecutor.ReviewContentAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode).GetAwaiter().GetResult(); - if (string.IsNullOrWhiteSpace(baselineReview?.RawScore) || string.IsNullOrWhiteSpace(currentReview?.RawScore)) - { - throw new InvalidOperationException("Unable to obtain review raw scores for delta benchmarks."); - } - - _deltaRequest = new ReviewDeltaRequest - { - OldScore = baselineReview!.RawScore!, - NewScore = currentReview!.RawScore!, - FilePath = _environment.ExistingFilePath, - FileContent = BenchmarkInputs.CurrentCode, - }; - - _deltaResponse = _cliExecutor.ReviewDeltaAsync(_deltaRequest).GetAwaiter().GetResult(); - if (_deltaResponse == null) - { - throw new InvalidOperationException("Unable to obtain a delta response for refactor benchmarks."); - } + _deltaResponse = new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m, ScoreChange = 1.5m }; _preflightResponse = _cliExecutor.PreflightAsync(force: true).GetAwaiter().GetResult(); if (_preflightResponse == null) @@ -58,18 +38,6 @@ public void Cleanup() _environment?.Dispose(); } - [Benchmark] - public Task ReviewContentAsync() - { - return _cliExecutor.ReviewContentAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task ReviewDeltaAsync() - { - return _cliExecutor.ReviewDeltaAsync(_deltaRequest); - } - [Benchmark] public Task> FnsToRefactorFromDeltaAsync() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CodeReviewerBenchmarks.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CodeReviewerBenchmarks.cs deleted file mode 100644 index d3cf4290..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CodeReviewerBenchmarks.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using BenchmarkDotNet.Attributes; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Models; - -namespace Codescene.VSExtension.Core.Benchmarks; - -[MemoryDiagnoser] -[JsonExporterAttribute.FullCompressed] -public class CodeReviewerBenchmarks -{ - private BenchmarkEnvironment _environment = null!; - private FixedGitService _gitService = null!; - private CodeReviewer _codeReviewer = null!; - private CodeReviewer _codeReviewerWithPreflight = null!; - private string _currentRawScore = null!; - private int _pathCounter; - - [GlobalSetup] - public void Setup() - { - new BaselineReviewCacheService().Clear(); - - _environment = new BenchmarkEnvironment(); - _gitService = new FixedGitService(BenchmarkInputs.BaselineCode); - _codeReviewer = _environment.CreateCodeReviewer(_gitService); - - var preflightResponse = _environment.CreateCliExecutor().PreflightAsync(force: true).GetAwaiter().GetResult(); - if (preflightResponse == null) - { - throw new InvalidOperationException("Unable to obtain a preflight response for CodeReviewer benchmarks."); - } - - _codeReviewerWithPreflight = _environment.CreateCodeReviewer(_gitService, new FixedPreflightManager(preflightResponse)); - - var currentReview = _environment.CreateCliExecutor().ReviewContentAsync(_environment.ExistingFilePath, BenchmarkInputs.CurrentCode).GetAwaiter().GetResult(); - if (string.IsNullOrWhiteSpace(currentReview?.RawScore)) - { - throw new InvalidOperationException("Unable to obtain a review raw score for CodeReviewer benchmarks."); - } - - _currentRawScore = currentReview!.RawScore!; - } - - [GlobalCleanup] - public void Cleanup() - { - new BaselineReviewCacheService().Clear(); - _environment?.Dispose(); - } - - [Benchmark] - public Task ReviewAsync() - { - return _codeReviewer.ReviewAsync(NextPath("review"), BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task GetOrComputeBaselineRawScoreAsync() - { - return _codeReviewer.GetOrComputeBaselineRawScoreAsync(NextPath("baseline"), BenchmarkInputs.BaselineCode); - } - - [Benchmark] - public Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync() - { - return _codeReviewer.ReviewAndBaselineAsync(NextPath("review-baseline"), BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task DeltaAsync() - { - return _codeReviewer.DeltaAsync( - new FileReviewModel { FilePath = NextPath("delta"), RawScore = _currentRawScore }, - BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task<(FileReviewModel review, Codescene.VSExtension.Core.Models.Cli.Delta.DeltaResponseModel delta)> ReviewWithDeltaAsync() - { - return _codeReviewer.ReviewWithDeltaAsync(NextPath("review-with-delta"), BenchmarkInputs.CurrentCode); - } - - [Benchmark] - public Task DeltaAsyncWithRefactorDiscovery() - { - return _codeReviewerWithPreflight.DeltaAsync( - new FileReviewModel { FilePath = NextPath("delta-refactor"), RawScore = _currentRawScore }, - BenchmarkInputs.CurrentCode); - } - - private string NextPath(string prefix) - { - var index = Interlocked.Increment(ref _pathCounter); - return Path.Combine(_environment.RootDirectory, prefix + "-" + index + ".cs"); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/Codescene.VSExtension.Core.Benchmarks.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/Codescene.VSExtension.Core.Benchmarks.csproj index e3c37b6e..7785a371 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/Codescene.VSExtension.Core.Benchmarks.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/Codescene.VSExtension.Core.Benchmarks.csproj @@ -7,6 +7,7 @@ enable enable true + $(NoWarn);VSTHRD002;VSTHRD003;VSTHRD110;VSTHRD200 diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/BatchGitIgnoreCheckerIntegrationTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/BatchGitIgnoreCheckerIntegrationTests.cs deleted file mode 100644 index 60510168..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/BatchGitIgnoreCheckerIntegrationTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.VS2022.Application.Git; -using LibGit2Sharp; -using Moq; - -namespace Codescene.VSExtension.Core.IntegrationTests -{ - [TestClass] - public class BatchGitIgnoreCheckerIntegrationTests - { - private string _testRepoPath = null!; - private BatchGitIgnoreChecker _checker = null!; - - [TestInitialize] - public void Setup() - { - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-git-repo-batch-ignore-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - _checker = new BatchGitIgnoreChecker(new Mock().Object); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testRepoPath)) - { - try - { - foreach (var file in Directory.GetFiles(_testRepoPath, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - [TestMethod] - public void FilterIgnored_UntrackedFileMatchingGitignore_IsExcluded() - { - File.WriteAllText(Path.Combine(_testRepoPath, ".gitignore"), "*.log\n"); - var ignoredPath = Path.Combine(_testRepoPath, "app.log"); - File.WriteAllText(ignoredPath, "content"); - - var result = _checker.FilterIgnored(new[] { ignoredPath }); - - Assert.IsEmpty(result); - } - - [TestMethod] - public void FilterIgnored_TrackedFileMatchingGitignore_IsIncluded() - { - var trackedPath = Path.Combine(_testRepoPath, "app.log"); - File.WriteAllText(trackedPath, "content"); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "app.log"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Track log file", signature, signature); - } - - File.WriteAllText(Path.Combine(_testRepoPath, ".gitignore"), "*.log\n"); - - var result = _checker.FilterIgnored(new[] { trackedPath }); - - Assert.HasCount(1, result); - CollectionAssert.Contains(result.ToList(), trackedPath); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/CodeHealthRulesReviewTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/CodeHealthRulesReviewTests.cs deleted file mode 100644 index b9e743de..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/CodeHealthRulesReviewTests.cs +++ /dev/null @@ -1,402 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Models.Cli.Review; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.IntegrationTests.CliExecutor -{ - [TestClass] - public class CodeHealthRulesReviewTests : BaseCliExecutorTests - { - private const string BumpyRoadSource = """ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; - -namespace Codescene.VSExtension.CodeSmells.Issues.CSharp -{ - class BumpyRoadExample - { - public void ProcessDirectory(string path) - { - var files = new List(); - var directory = new DirectoryInfo(path); - - foreach (FileInfo fileInfo in directory.GetFiles()) - { - if (Regex.IsMatch(fileInfo.Name, @"^data\d+\.csv$")) - { - files.Add(fileInfo.FullName); - } - } - - var sb = new StringBuilder(); - foreach (string filePath in files) - { - using (var reader = new StreamReader(filePath)) - { - string line; - while ((line = reader.ReadLine()) != null) - { - sb.Append(line); - } - } - } - - using (var writer = new StreamWriter("data.csv")) - { - writer.Write(sb.ToString()); - } - } - } -} -"""; - - private const string RulesBumpyRoadWeight0 = """ -{ - "rule_sets": [ - { - "matching_content_path": "**/*", - "rules": [ - { - "name": "Bumpy Road Ahead", - "weight": 0.0 - } - ] - } - ] -} -"""; - - private const string RulesBumpyRoadWeight1 = """ -{ - "rule_sets": [ - { - "matching_content_path": "**/*", - "rules": [ - { - "name": "Bumpy Road Ahead", - "weight": 1.0 - } - ] - } - ] -} -"""; - - private const string RulesFunctionMaxArguments7 = """ -{ - "rule_sets": [ - { - "matching_content_path": "**/*", - "rules": [], - "thresholds": [ - { - "name": "function_max_arguments", - "value": "7" - } - ] - } - ] -} -"""; - - private const string EightParameterMethodSource = """ -namespace ArgsThreshold; - -public static class EightParams -{ - public static void M(int a, int b, int c, int d, int e, int f, int g, int h) - { - } -} -"""; - - private const string SixParameterMethodSource = """ -namespace ArgsThreshold; - -public static class SixParams -{ - public static void M(int a, int b, int c, int d, int e, int f) - { - } -} -"""; - - private string? _gitRepoRoot; - - [TestInitialize] - public override void Initialize() => base.Initialize(); - - [TestCleanup] - public override void Cleanup() - { - TryDeleteGitRepo(); - base.Cleanup(); - } - - [TestMethod] - public async Task ReviewContentAsync_BumpyRoadWeightZero_ScoreTenNoFunctionLevelSmells() - { - var filePath = PrepareGitRepoWithRules(RulesBumpyRoadWeight0, "src/BumpyRoad.cs", BumpyRoadSource); - try - { - var result = await cliExecutor.ReviewContentAsync(filePath, BumpyRoadSource); - - Assert.IsNotNull(result); - Helpers.AssertNoCodeHealthRulesError(result); - Assert.AreEqual(10f, result.Score!.Value, 0.01f); - Assert.IsTrue( - result.FunctionLevelCodeSmells == null || result.FunctionLevelCodeSmells.Count == 0, - "Bumpy Road weight 0 should yield no function-level code smells"); - } - finally - { - TryDeleteGitRepo(); - } - } - - [TestMethod] - public async Task ReviewContentAsync_BumpyRoadWeightOne_RetainsSmell() - { - var filePath = PrepareGitRepoWithRules(RulesBumpyRoadWeight1, "src/BumpyRoad.cs", BumpyRoadSource); - try - { - var result = await cliExecutor.ReviewContentAsync(filePath, BumpyRoadSource); - - Assert.IsNotNull(result); - Helpers.AssertNoCodeHealthRulesError(result); - Assert.IsTrue( - Helpers.TotalSmellCount(result) > 0 || Helpers.HasSmellCategoryContaining(result, "Bumpy Road"), - "Bumpy Road should contribute when weight is 1.0"); - } - finally - { - TryDeleteGitRepo(); - } - } - - [TestMethod] - public async Task ReviewContentAsync_NoCodeHealthRules_BumpyRoadNotSuppressed() - { - var filePath = PrepareGitRepoWithoutRules("src/BumpyRoad.cs", BumpyRoadSource); - try - { - var result = await cliExecutor.ReviewContentAsync(filePath, BumpyRoadSource); - - Assert.IsNotNull(result); - Helpers.AssertNoCodeHealthRulesError(result); - Assert.IsTrue( - Helpers.TotalSmellCount(result) > 0 || result.Score < 10, - "Default rules should surface complexity / Bumpy Road for this fixture"); - } - finally - { - TryDeleteGitRepo(); - } - } - - [TestMethod] - public async Task ReviewContentAsync_FunctionMaxArgumentsThreshold_ExceedingLimit_Reported() - { - var filePath = PrepareGitRepoWithRules(RulesFunctionMaxArguments7, "src/Args.cs", EightParameterMethodSource); - try - { - var result = await cliExecutor.ReviewContentAsync(filePath, EightParameterMethodSource); - - Assert.IsNotNull(result); - Helpers.AssertNoCodeHealthRulesError(result); - Assert.IsTrue( - Helpers.HasTooManyArgumentsSmell(result), - "Eight parameters should exceed function_max_arguments threshold of 7"); - } - finally - { - TryDeleteGitRepo(); - } - } - - [TestMethod] - public async Task ReviewContentAsync_FunctionMaxArgumentsThreshold_UnderLimit_NotReported() - { - var filePath = PrepareGitRepoWithRules(RulesFunctionMaxArguments7, "src/Args.cs", SixParameterMethodSource); - try - { - var result = await cliExecutor.ReviewContentAsync(filePath, SixParameterMethodSource); - - Assert.IsNotNull(result); - Helpers.AssertNoCodeHealthRulesError(result); - Assert.IsFalse( - Helpers.HasTooManyArgumentsSmell(result), - "Six parameters should not exceed function_max_arguments threshold of 7"); - } - finally - { - TryDeleteGitRepo(); - } - } - - private string PrepareGitRepoWithRules(string rulesJson, string sourceRelativePath, string sourceContent) - { - TryDeleteGitRepo(); - _gitRepoRoot = Path.Combine(Path.GetTempPath(), "codescene-ch-rules", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_gitRepoRoot); - Repository.Init(_gitRepoRoot); - using (var repo = new Repository(_gitRepoRoot)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - WriteRepoFile(".codescene/code-health-rules.json", rulesJson); - WriteRepoFile(sourceRelativePath, sourceContent); - StageAndCommit(new[] { ".codescene/code-health-rules.json", sourceRelativePath }); - - return Path.GetFullPath(Path.Combine(_gitRepoRoot, sourceRelativePath.Replace('/', Path.DirectorySeparatorChar))); - } - - private string PrepareGitRepoWithoutRules(string sourceRelativePath, string sourceContent) - { - TryDeleteGitRepo(); - _gitRepoRoot = Path.Combine(Path.GetTempPath(), "codescene-ch-rules", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_gitRepoRoot); - Repository.Init(_gitRepoRoot); - using (var repo = new Repository(_gitRepoRoot)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - WriteRepoFile(sourceRelativePath, sourceContent); - StageAndCommit(new[] { sourceRelativePath }); - - return Path.GetFullPath(Path.Combine(_gitRepoRoot, sourceRelativePath.Replace('/', Path.DirectorySeparatorChar))); - } - - private void WriteRepoFile(string relativePath, string content) - { - var fullPath = Path.Combine(_gitRepoRoot!, relativePath.Replace('/', Path.DirectorySeparatorChar)); - var dir = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrEmpty(dir)) - { - Directory.CreateDirectory(dir); - } - - File.WriteAllText(fullPath, content); - } - - private void StageAndCommit(string[] relativePaths) - { - using var repo = new Repository(_gitRepoRoot!); - foreach (var p in relativePaths) - { - Commands.Stage(repo, p); - } - - var sig = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("test", sig, sig); - } - - private void TryDeleteGitRepo() - { - if (string.IsNullOrEmpty(_gitRepoRoot) || !Directory.Exists(_gitRepoRoot)) - { - return; - } - - try - { - Directory.Delete(_gitRepoRoot, recursive: true); - } - catch - { - } - - _gitRepoRoot = null; - } - - private static class Helpers - { - public static int TotalSmellCount(CliReviewModel r) - { - var n = r.FileLevelCodeSmells?.Count ?? 0; - if (r.FunctionLevelCodeSmells != null) - { - foreach (var fn in r.FunctionLevelCodeSmells) - { - if (fn.CodeSmells != null) - { - n += fn.CodeSmells.Length; - } - } - } - - return n; - } - - public static bool HasSmellCategoryContaining(CliReviewModel r, string substring) - { - foreach (var s in EnumerateAllSmells(r)) - { - if (CategoryContainsSubstring(s, substring)) - { - return true; - } - } - - return false; - } - - public static bool HasTooManyArgumentsSmell(CliReviewModel r) - { - return HasSmellCategoryContaining(r, "argument"); - } - - public static void AssertNoCodeHealthRulesError(CliReviewModel r) - { - if (r.CodeHealthRulesError != null) - { - Assert.Fail( - $"code-health-rules-error: {r.CodeHealthRulesError.Description} remedy: {r.CodeHealthRulesError.Remedy}"); - } - } - - private static IEnumerable EnumerateAllSmells(CliReviewModel r) - { - if (r.FileLevelCodeSmells != null) - { - foreach (var s in r.FileLevelCodeSmells) - { - yield return s; - } - } - - if (r.FunctionLevelCodeSmells == null) - { - yield break; - } - - foreach (var fn in r.FunctionLevelCodeSmells) - { - if (fn.CodeSmells == null) - { - continue; - } - - foreach (var s in fn.CodeSmells) - { - yield return s; - } - } - } - - private static bool CategoryContainsSubstring(CliCodeSmellModel smell, string substring) - { - return smell.Category != null - && smell.Category.Contains(substring, StringComparison.OrdinalIgnoreCase); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs deleted file mode 100644 index 0a219363..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.IntegrationTests.CliExecutor -{ - [TestClass] - public class ReviewContentTests : BaseCliExecutorTests - { - [TestInitialize] - public override void Initialize() => base.Initialize(); - - [TestCleanup] - public override void Cleanup() => base.Cleanup(); - - [TestMethod] - public async Task ReviewContentAsync_ValidCSharpCode_ReturnsValidReview() - { - var filename = "Test.cs"; - var content = @" -public class Calculator -{ - public int Add(int a, int b) - { - return a + b; - } -}"; - var dir = Path.Combine(Path.GetTempPath(), "codescene-cli-it", Guid.NewGuid().ToString()); - Directory.CreateDirectory(dir); - var filePath = Path.GetFullPath(Path.Combine(dir, filename)); - try - { - File.WriteAllText(filePath, content); - - var result = await cliExecutor.ReviewContentAsync(filePath, content); - - Assert.IsNotNull(result, "CLI should return a review result for valid code"); - Assert.IsTrue(result.Score.HasValue, "Review should have a score"); - Assert.IsTrue(result.Score >= 0 && result.Score <= 10, "Score should be between 0 and 10"); - Assert.IsFalse(string.IsNullOrEmpty(result.RawScore), "Review should have a raw score for delta calculations"); - } - finally - { - TryDeleteDirectory(dir); - } - } - - [TestMethod] - public async Task ReviewContentAsync_ValidJavaScriptCode_ReturnsValidReview() - { - var filename = "test.js"; - var content = @" -function calculateSum(numbers) { - return numbers.reduce((sum, num) => sum + num, 0); -} - -module.exports = { calculateSum }; -"; - var dir = Path.Combine(Path.GetTempPath(), "codescene-cli-it", Guid.NewGuid().ToString()); - Directory.CreateDirectory(dir); - var filePath = Path.GetFullPath(Path.Combine(dir, filename)); - try - { - File.WriteAllText(filePath, content); - - var result = await cliExecutor.ReviewContentAsync(filePath, content); - - Assert.IsNotNull(result, "CLI should return a review result for valid JavaScript code"); - Assert.IsTrue(result.Score.HasValue, "Review should have a score"); - } - finally - { - TryDeleteDirectory(dir); - } - } - - [TestMethod] - public async Task ReviewContentAsync_ComplexCode_ReturnsCodeSmells() - { - var filename = "Complex.cs"; - var content = @" -public class ComplexProcessor -{ - public void ProcessData(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) - { - if (a > 0) - { - if (b > 0) - { - if (c > 0) - { - if (d > 0) - { - if (e > 0) - { - Console.WriteLine(""Deep nesting""); - } - } - } - } - } - } -}"; - var dir = Path.Combine(Path.GetTempPath(), "codescene-cli-it", Guid.NewGuid().ToString()); - Directory.CreateDirectory(dir); - var filePath = Path.GetFullPath(Path.Combine(dir, filename)); - try - { - File.WriteAllText(filePath, content); - - var result = await cliExecutor.ReviewContentAsync(filePath, content); - - Assert.IsNotNull(result, "CLI should return a review result"); - Assert.IsTrue(result.Score.HasValue, "Review should have a score"); - } - finally - { - TryDeleteDirectory(dir); - } - } - - private static void TryDeleteDirectory(string dir) - { - if (!Directory.Exists(dir)) - { - return; - } - - try - { - Directory.Delete(dir, recursive: true); - } - catch - { - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewDeltaTests.cs deleted file mode 100644 index 79672793..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewDeltaTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Models.Cli.Delta; - -namespace Codescene.VSExtension.Core.IntegrationTests.CliExecutor -{ - [TestClass] - public class ReviewDeltaTests : BaseCliExecutorTests - { - [TestInitialize] - public override void Initialize() => base.Initialize(); - - [TestCleanup] - public override void Cleanup() => base.Cleanup(); - - [TestMethod] - public async Task ReviewDeltaAsync_WithValidScores_ReturnsDeltaResponse() - { - var filename = "Test.cs"; - var simpleCode = @" -public class Simple -{ - public int Add(int a, int b) => a + b; -}"; - var complexCode = @" -public class Complex -{ - public int Calculate(int a, int b, int c, int d, int e, int f, int g, int h) - { - if (a > 0) { - if (b > 0) { - if (c > 0) { - return a + b + c + d + e + f + g + h; - } - } - } - return 0; - } -}"; - - var dir = Path.Combine(Path.GetTempPath(), "codescene-cli-it", Guid.NewGuid().ToString()); - Directory.CreateDirectory(dir); - var filePath = Path.GetFullPath(Path.Combine(dir, filename)); - try - { - File.WriteAllText(filePath, simpleCode); - var simpleReview = await cliExecutor.ReviewContentAsync(filePath, simpleCode); - - File.WriteAllText(filePath, complexCode); - var complexReview = await cliExecutor.ReviewContentAsync(filePath, complexCode); - - if (string.IsNullOrEmpty(simpleReview?.RawScore) || string.IsNullOrEmpty(complexReview?.RawScore)) - { - Assert.Inconclusive("Could not obtain raw scores for delta comparison"); - return; - } - - var delta = await cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = simpleReview!.RawScore!, NewScore = complexReview!.RawScore! }); - - Assert.IsNotNull(delta, "CLI should return a delta response"); - } - finally - { - TryDeleteDirectory(dir); - } - } - - private static void TryDeleteDirectory(string dir) - { - if (!Directory.Exists(dir)) - { - return; - } - - try - { - Directory.Delete(dir, recursive: true); - } - catch - { - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/Codescene.VSExtension.Core.IntegrationTests.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/Codescene.VSExtension.Core.IntegrationTests.csproj index c96adda7..eaa074ad 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/Codescene.VSExtension.Core.IntegrationTests.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/Codescene.VSExtension.Core.IntegrationTests.csproj @@ -6,6 +6,7 @@ enable enable true + $(NoWarn);VSTHRD002;VSTHRD003;VSTHRD103;VSTHRD110;VSTHRD200 @@ -15,15 +16,15 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers - + diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestGitService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestGitService.cs index db86b343..d5e2c6c4 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestGitService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestGitService.cs @@ -1,7 +1,5 @@ // Copyright (c) CodeScene. All rights reserved. -using System; -using System.Collections.Generic; using System.ComponentModel.Composition; using Codescene.VSExtension.Core.Interfaces.Git; using Moq; @@ -14,31 +12,11 @@ internal class TestGitService : IGitService { internal Mock Mock = new Mock(); - public string GetBaselineCommit(string repoRootPath) - { - return Mock.Object.GetBaselineCommit(repoRootPath); - } - - public string GetFileContentForCommit(string path) - { - return Mock.Object.GetFileContentForCommit(path); - } - - public string GetFileContentForCommit(string path, string baselineCommit) - { - return Mock.Object.GetFileContentForCommit(path, baselineCommit); - } - public bool IsFileIgnored(string filePath) { return Mock.Object.IsFileIgnored(filePath); } - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - return new HashSet(absolutePaths, StringComparer.OrdinalIgnoreCase); - } - public void Dispose() { } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs index 9ebc3f5f..d1d498d0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs @@ -15,5 +15,11 @@ internal class TestSettingsProvider : ISettingsProvider public bool ShowDebugLogs => Mock.Object.ShowDebugLogs; public string AuthToken => Mock.Object.AuthToken; + + public int ServerWorkerThreads => Mock.Object.ServerWorkerThreads; + + public bool EnableAutomaticAnalysis => true; + + public bool EnableTelemetry => true; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/Codescene.VSExtension.Core.SubcutaneousTests.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/Codescene.VSExtension.Core.SubcutaneousTests.csproj index 4c0bf10d..287c064f 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/Codescene.VSExtension.Core.SubcutaneousTests.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/Codescene.VSExtension.Core.SubcutaneousTests.csproj @@ -6,6 +6,7 @@ enable enable true + $(NoWarn);VSTHRD002;VSTHRD003;VSTHRD103;VSTHRD110;VSTHRD200 @@ -15,8 +16,9 @@ - - + + + all runtime; build; native; contentfiles; analyzers diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/FileWatcherSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/FileWatcherSubcutaneousTests.cs deleted file mode 100644 index 3f9d92f7..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/FileWatcherSubcutaneousTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -[TestClass] -public class FileWatcherSubcutaneousTests : SubcutaneousGitTestBase -{ - protected override int GitPollingIntervalSeconds => 30; - - [TestMethod] - public async Task FileCreate_BecomesTrackedAndReviewedOnce() - { - const string relativePath = "src/CreateTracked.cs"; - - await WriteWorkingFileAsync(relativePath, "public class CreateTracked { public int Value => 1; }"); - - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - await Task.Delay(1500); - SnapshotState("post-create", relativePath); - - Assert.AreEqual(1, ReviewCount(relativePath), Journal.Dump()); - } - - [TestMethod] - public async Task RapidRepeatedWrites_BeforeFirstTick_ResultInSingleReview() - { - const string relativePath = "src/RapidWrites.cs"; - - await WriteWorkingFileAsync(relativePath, "public class RapidWrites { public int Value => 1; }"); - await AppendWorkingFileAsync(relativePath, Environment.NewLine + "public int Value2 => 2;"); - await AppendWorkingFileAsync(relativePath, Environment.NewLine + "public int Value3 => 3;"); - - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - await Task.Delay(1500); - SnapshotState("post-rapid-writes", relativePath); - - Assert.AreEqual(1, ReviewCount(relativePath), Journal.Dump()); - } - - [TestMethod] - public async Task FileModify_AfterInitialReview_ProducesSingleAdditionalReview() - { - const string relativePath = "src/SingleAdditionalReview.cs"; - - await WriteWorkingFileAsync(relativePath, "public class SingleAdditionalReview { public int Value => 1; }"); - await WaitForReviewCountAsync(relativePath, 1); - - await WriteWorkingFileAsync(relativePath, "public class SingleAdditionalReview { public int Value => 2; }"); - await WriteWorkingFileAsync(relativePath, "public class SingleAdditionalReview { public int Value => 3; }"); - - await WaitForReviewCountAsync(relativePath, 2); - await Task.Delay(1500); - SnapshotState("post-modify", relativePath); - - Assert.AreEqual(2, ReviewCount(relativePath), Journal.Dump()); - } - - [TestMethod] - public async Task FileCreatedThenDeletedWhileReviewBlocked_DoesNotLeaveStaleDelta() - { - const string relativePath = "src/DeleteDuringReview.cs"; - var absolutePath = AbsolutePath(relativePath); - var block = CodeReviewer.BlockNextReview(absolutePath); - try - { - await WriteWorkingFileAsync(relativePath, "public class DeleteDuringReview { public int Value => 1; }"); - await WaitForConditionAsync(() => block.Entered.IsCompleted, "The blocked review never started."); - - DeleteWorkingFile(relativePath); - - block.Release(); - await TaskScheduler.WaitForIdleAsync(10000); - await WaitForNotTrackedAsync(relativePath); - await WaitForNoDeltaAsync(relativePath); - SnapshotState("post-delete-during-review", relativePath); - - Assert.IsGreaterThanOrEqualTo(Journal.Count("observer.file-deleted", absolutePath), 1, Journal.Dump()); - } - finally - { - block.Release(); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitCommandSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitCommandSubcutaneousTests.cs deleted file mode 100644 index 14e2c895..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitCommandSubcutaneousTests.cs +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -[TestClass] -public class GitCommandSubcutaneousTests : SubcutaneousGitTestBase -{ - protected override int GitPollingIntervalSeconds => 5; - - protected override bool AutoStartObserver => false; - - [TestMethod] - public async Task GitCheckout_SwitchesCommittedChangeInAndOutOfMonitor() - { - const string relativePath = "src/BranchSwitch.cs"; - - await CreateCommittedFileAsync(relativePath, "public class BranchSwitch { public int Value => 1; }", "Add branch switch file"); - CheckoutBranch("feature", create: true); - await WriteWorkingFileAsync(relativePath, "public class BranchSwitch { public int Value => 2; }"); - CommitAll("Change branch switch file on feature"); - CheckoutBranch("main"); - - await StartObserverAsync(); - - CheckoutBranch("feature"); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - CheckoutBranch("main"); - await WaitForNotTrackedAsync(relativePath); - await WaitForNoDeltaAsync(relativePath); - SnapshotState("post-checkout-main", relativePath); - } - - [TestMethod] - public async Task BranchSwitchReset_ClearsRunningDeltaJobForBlockedReview() - { - const string relativePath = "src/InfiniteSpinner.cs"; - var absolutePath = AbsolutePath(relativePath); - const string changedContent = @"public class InfiniteSpinner -{ - public int Compute(int value, int factor, bool includeBonus) - { - var total = value + factor; - - if (includeBonus) - { - total += 5; - } - - if (total > 10) - { - if (total % 2 == 0) - { - total += factor; - } - else - { - total -= value; - } - } - else if (total > 5) - { - total = total * 2; - } - else - { - total = total - 3; - } - - return total; - } -}"; - - await CreateCommittedFileAsync(relativePath, "public class InfiniteSpinner { public int Value => 1; }", "Add spinner file"); - CheckoutBranch("feature", create: true); - - var block = CliExecutor.BlockNextDelta(absolutePath); - try - { - await StartObserverAsync(); - await WriteWorkingFileAsync(relativePath, changedContent); - await WaitForTrackedAsync(relativePath); - await WaitForConditionAsync(() => block.Entered.IsCompleted, "The blocked delta review never started."); - await WaitForConditionAsync(() => HasRunningDeltaJob(relativePath), "Expected a running delta job before the branch reset."); - - CheckoutBranch("main"); - Observer.CancelAndReset(); - - await WaitForConditionAsync( - () => RunningDeltaJobCount() == 0, - $"Expected branch reset to clear all running delta jobs, but found {RunningDeltaJobCount()}."); - SnapshotState("post-branch-switch-reset", relativePath); - - block.Release(); - await TaskScheduler.WaitForIdleAsync(10000); - - Assert.AreEqual(0, RunningDeltaJobCount(), Journal.Dump()); - } - finally - { - block.Release(); - } - } - - [TestMethod] - public async Task BranchSwitch_StaleDeltaFromPreviousBranch_DoesNotLeakIntoCache() - { - const string branchAPath = "src/BranchAOnly.cs"; - const string branchBPath = "src/BranchBOnly.cs"; - const string baseContent = "public class SharedBase { public int Value => 1; }"; - const string branchAContent = @"public class SharedBase -{ - public int ComputeAlpha(int first, int second, bool includeThirdStep) - { - var total = first + second; - - if (includeThirdStep) - { - total += 7; - } - - if (total > 20) - { - total = total - first; - } - else if (total > 10) - { - total = total * 2; - } - else - { - total = total + second; - } - - return total; - } -}"; - const string branchBContent = @"public class SharedBase -{ - public int ComputeBeta(int value) - { - var current = value; - - for (var index = 0; index < 3; index++) - { - current += index; - } - - if (current % 2 == 0) - { - current = current / 2; - } - else - { - current = current + 9; - } - - return current; - } -}"; - - await CreateCommittedFileAsync(branchAPath, baseContent, "Add branch a base file"); - await CreateCommittedFileAsync(branchBPath, baseContent, "Add branch b base file"); - - CheckoutBranch("branch-a", create: true); - await WriteWorkingFileAsync(branchAPath, branchAContent); - CommitAll("Change file on branch a"); - - CheckoutBranch("main"); - CheckoutBranch("branch-b", create: true); - await WriteWorkingFileAsync(branchBPath, branchBContent); - CommitAll("Change file on branch b"); - - CheckoutBranch("main"); - - var absoluteBranchAPath = AbsolutePath(branchAPath); - var absoluteBranchBPath = AbsolutePath(branchBPath); - var block = CliExecutor.BlockNextDelta(absoluteBranchAPath); - try - { - await StartObserverAsync(); - - CheckoutBranch("branch-a"); - await WaitForConditionAsync(() => block.Entered.IsCompleted, "The branch a delta review never started."); - await WaitForConditionAsync(() => HasRunningDeltaJob(branchAPath), "Expected a running delta job for branch a."); - - CheckoutBranch("branch-b"); - Observer.CancelAndReset(); - - await WaitForTrackedAsync(branchBPath); - await WaitForReviewCountAsync(branchBPath, 1); - SnapshotState("branch-b-visible", branchAPath, branchBPath); - - block.Release(); - await TaskScheduler.WaitForIdleAsync(10000); - await WaitForConditionAsync( - () => !HasDelta(branchAPath), - "Expected stale branch a delta to stay out of the cache after switching to branch b."); - SnapshotState("post-branch-switch-cache-race", branchAPath, branchBPath); - - Assert.IsFalse( - DeltaCachePaths().Any(p => string.Equals(p, absoluteBranchAPath, StringComparison.OrdinalIgnoreCase)), - Journal.Dump()); - Assert.IsTrue( - DeltaCachePaths().All(p => string.Equals(p, absoluteBranchBPath, StringComparison.OrdinalIgnoreCase)), - Journal.Dump()); - } - finally - { - block.Release(); - } - } - - [TestMethod] - public async Task GitCheckoutFile_RevertsWorkingTreeChangeAndClearsTrackedState() - { - const string relativePath = "src/CheckoutRestore.cs"; - - await CreateCommittedFileAsync(relativePath, "public class CheckoutRestore { public int Value => 1; }", "Add checkout restore file"); - - await StartObserverAsync(); - await WriteWorkingFileAsync(relativePath, "public class CheckoutRestore { public int Value => 2; }"); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - ExecGit("checkout -- src/CheckoutRestore.cs"); - - await WaitForNotTrackedAsync(relativePath); - await WaitForNoDeltaAsync(relativePath); - SnapshotState("post-checkout-file-restore", relativePath); - } - - [TestMethod] - public async Task GitMove_TracksNewPathAndClearsOldPath() - { - const string oldRelativePath = "src/OldName.cs"; - const string newRelativePath = "src/NewName.cs"; - - await StartObserverAsync(); - await WriteWorkingFileAsync(oldRelativePath, "public class OldName { public int Value => 1; }"); - await WaitForTrackedAsync(oldRelativePath); - await WaitForReviewCountAsync(oldRelativePath, 1); - - ExecGit("add src/OldName.cs"); - ExecGit("mv src/OldName.cs src/NewName.cs"); - - await WaitForTrackedAsync(newRelativePath); - await WaitForReviewCountAsync(newRelativePath, 1); - await WaitForNotTrackedAsync(oldRelativePath); - await WaitForNoDeltaAsync(oldRelativePath); - SnapshotState("post-git-mv", oldRelativePath, newRelativePath); - } - - [TestMethod] - public async Task GitResetHard_RemovesFeatureOnlyCommittedFile() - { - const string relativePath = "src/ResetTracked.cs"; - - CheckoutBranch("feature", create: true); - await CreateCommittedFileAsync(relativePath, "public class ResetTracked { public int Value => 1; }", "Add feature-only file"); - - await StartObserverAsync(); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - ResetHard("HEAD~1"); - - await WaitForNotTrackedAsync(relativePath); - await WaitForNoDeltaAsync(relativePath); - SnapshotState("post-reset-hard", relativePath); - } - - [TestMethod] - public async Task GitStashPushAndPop_TogglesTrackedStateForUntrackedFile() - { - const string relativePath = "src/StashedFile.cs"; - - await StartObserverAsync(); - await WriteWorkingFileAsync(relativePath, "public class StashedFile { public int Value => 1; }"); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - StashPush("stash untracked file", includeUntracked: true); - - await WaitForNotTrackedAsync(relativePath); - await WaitForNoDeltaAsync(relativePath); - - StashPop(); - - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 2); - SnapshotState("post-stash-pop", relativePath); - } - - [TestMethod] - public async Task GitStashPopConflict_LeavesFileTrackedWithoutParallelReviews() - { - const string relativePath = "src/StashConflict.cs"; - var absolutePath = AbsolutePath(relativePath); - - await CreateCommittedFileAsync(relativePath, "public class StashConflict { public int Value => 1; }", "Add stash conflict file"); - - await StartObserverAsync(); - - await WriteWorkingFileAsync(relativePath, "public class StashConflict { public int Value => 2; }"); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - StashPush("stash conflicting change"); - - await WriteWorkingFileAsync(relativePath, "public class StashConflict { public int Value => 3; }"); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 2); - CommitAll("Commit conflicting change"); - - var result = StashPopAllowFailure(); - - Assert.AreNotEqual(0, result.ExitCode, Journal.Dump()); - - await WaitForConditionAsync( - () => System.IO.File.ReadAllText(absolutePath).Contains("<<<<<<<"), - "Expected stash pop to leave conflict markers in the file."); - await WaitForTrackedAsync(relativePath); - await Task.Delay(1500); - SnapshotState("post-stash-conflict", relativePath); - - Assert.AreEqual(1, MaxParallelReviews(relativePath), Journal.Dump()); - } - - [TestMethod] - public async Task GitRebase_KeepsFeatureChangeTrackedWithoutParallelReviews() - { - const string relativePath = "src/RebasedFile.cs"; - - await CreateCommittedFileAsync(relativePath, "public class RebasedFile { public int Value => 1; }", "Add rebased file on main"); - CheckoutBranch("feature", create: true); - await WriteWorkingFileAsync(relativePath, "public class RebasedFile { public int Value => 2; }"); - CommitAll("Feature change for rebase"); - - CheckoutBranch("main"); - await CreateCommittedFileAsync("src/MainSideChange.cs", "public class MainSideChange { public int Value => 1; }", "Add main side change"); - CheckoutBranch("feature"); - - await StartObserverAsync(); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - - var baselineParallel = MaxParallelReviews(relativePath); - - RebaseOnto("main"); - var journalCountAfterRebaseGit = Journal.Snapshot().Count; - - await WaitForTrackedAsync(relativePath); - await WaitForConditionAsync( - () => Journal.Snapshot().Count > journalCountAfterRebaseGit || MaxParallelReviews(relativePath) != baselineParallel, - "Expected observer journal activity after rebase was processed."); - SnapshotState("post-rebase", relativePath); - - Assert.AreEqual(1, MaxParallelReviews(relativePath), Journal.Dump()); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs deleted file mode 100644 index f71a2b6d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -[TestClass] -public class GitServiceSubcutaneousTests : SubcutaneousGitTestBase -{ - private const string MainContent = @"namespace TestingCodeSceneVS.CSharp -{ - internal class TesfFile - { - public void TestMethod(string t1) - { - Console.WriteLine(""Hello world""); - } - } -}"; - - private const string DevelopContent = @"namespace TestingCodeSceneVS.CSharp -{ - internal class TesfFile - { - public void TestMethod(string t1) - { - Console.WriteLine(""Hello world""); - } - public void Hello(string t1, string t2, string t3, string t4, string t5) - { - Console.WriteLine(""Hello""); - } - } -}"; - - private const string FeatureContent = @"namespace TestingCodeSceneVS.CSharp -{ - internal class TesfFile - { - public void TestMethod(string t1) - { - Console.WriteLine(""Hello world""); - } - public void Hello(string t1, string t2, string t3, string t4, string t5) - { - Console.WriteLine(""Hello""); - if (true) { if (true) { if (true) { if (true) { if (true) { } } } } } - } - } -}"; - - protected override bool AutoStartObserver => false; - - [TestMethod] - public async Task GetFileContentForCommit_OnFeatureBranchBasedOnDevelop_UsesDevelopBaseline() - { - const string relativePath = "src/TesfFile.cs"; - - await CreateCommittedFileAsync(relativePath, MainContent, "Add baseline file on main"); - - CheckoutBranch("develop", create: true); - await WriteWorkingFileAsync(relativePath, DevelopContent); - CommitAll("Improve baseline file on develop"); - - string developCommit; - using (var repo = new Repository(RepositoryRoot)) - { - developCommit = repo.Head.Tip.Sha; - } - - CheckoutBranch("feature/test-suppression2", create: true); - await WriteWorkingFileAsync(relativePath, FeatureContent); - - var baselineCommit = GitService.GetBaselineCommit(RepositoryRoot); - Assert.AreEqual(developCommit, baselineCommit); - - var baselineContent = GitService.GetFileContentForCommit(AbsolutePath(relativePath)); - Assert.AreEqual(NormalizeLineEndings(DevelopContent), NormalizeLineEndings(baselineContent)); - Assert.AreNotEqual(NormalizeLineEndings(MainContent), NormalizeLineEndings(baselineContent)); - } - - [TestMethod] - public async Task GetFileContentForCommit_WithOriginHeadMain_WithoutConfig_UsesMainBaselineNotDevelop() - { - const string relativePath = "src/TesfFile.cs"; - - await CreateCommittedFileAsync(relativePath, MainContent, "Add baseline file on main"); - - CheckoutBranch("develop", create: true); - await WriteWorkingFileAsync(relativePath, DevelopContent); - CommitAll("Improve baseline file on develop"); - - SetOriginHeadToBranch("main"); - - CheckoutBranch("feature/config-baseline", create: true); - await WriteWorkingFileAsync(relativePath, FeatureContent); - - var baselineContent = GitService.GetFileContentForCommit(AbsolutePath(relativePath)); - Assert.AreEqual(NormalizeLineEndings(MainContent), NormalizeLineEndings(baselineContent)); - Assert.AreNotEqual(NormalizeLineEndings(DevelopContent), NormalizeLineEndings(baselineContent)); - } - - [TestMethod] - public async Task GetFileContentForCommit_WithConfigBaselineDevelop_OverridesOriginHeadMain() - { - const string relativePath = "src/TesfFile.cs"; - - await CreateCommittedFileAsync(relativePath, MainContent, "Add baseline file on main"); - - CheckoutBranch("develop", create: true); - await WriteWorkingFileAsync(relativePath, DevelopContent); - CommitAll("Improve baseline file on develop"); - - SetOriginHeadToBranch("main"); - WriteCodesceneConfig("develop"); - - CheckoutBranch("feature/config-baseline-override", create: true); - await WriteWorkingFileAsync(relativePath, FeatureContent); - - var baselineContent = GitService.GetFileContentForCommit(AbsolutePath(relativePath)); - Assert.AreEqual(NormalizeLineEndings(DevelopContent), NormalizeLineEndings(baselineContent)); - Assert.AreNotEqual(NormalizeLineEndings(MainContent), NormalizeLineEndings(baselineContent)); - } - - [TestMethod] - public async Task ReviewWithDeltaAsync_OnFeatureBranchBasedOnDevelop_UsesDevelopBaselineForActualCliScores() - { - const string relativePath = "src/TesfFile.cs"; - - var absolutePath = await CreateCommittedFileAsync(relativePath, MainContent, "Add baseline file on main"); - var mainReview = await CodeReviewer.ReviewAsync(absolutePath, MainContent); - Assert.AreEqual(10.00m, RoundScore(mainReview.Score)); - - CheckoutBranch("develop", create: true); - await WriteWorkingFileAsync(relativePath, DevelopContent); - CommitAll("Improve baseline file on develop"); - - var developReview = await CodeReviewer.ReviewAsync(absolutePath, DevelopContent); - Assert.AreEqual(9.68m, RoundScore(developReview.Score)); - - CheckoutBranch("feature/test-suppression2", create: true); - await WriteWorkingFileAsync(relativePath, FeatureContent); - - var baselineContent = GitService.GetFileContentForCommit(absolutePath); - Assert.AreEqual(NormalizeLineEndings(DevelopContent), NormalizeLineEndings(baselineContent)); - - var (featureReview, delta) = await CodeReviewer.ReviewWithDeltaAsync(absolutePath, FeatureContent); - - Assert.AreEqual(9.09m, RoundScore(featureReview.Score)); - Assert.IsNotNull(delta); - Assert.AreEqual(9.68m, RoundScore(delta.OldScore)); - Assert.AreEqual(9.09m, RoundScore(delta.NewScore)); - Assert.AreEqual(-0.59m, RoundScore(delta.ScoreChange)); - } - - private static decimal RoundScore(float score) - { - return decimal.Round(System.Convert.ToDecimal(score), 2, MidpointRounding.AwayFromZero); - } - - private static decimal RoundScore(decimal score) - { - return decimal.Round(score, 2, MidpointRounding.AwayFromZero); - } - - private static string NormalizeLineEndings(string text) - { - return text.Replace("\r\n", "\n").Replace('\r', '\n'); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/PollingRaceSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/PollingRaceSubcutaneousTests.cs deleted file mode 100644 index 35c76a67..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/PollingRaceSubcutaneousTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -[TestClass] -public class PollingRaceSubcutaneousTests : SubcutaneousGitTestBase -{ - protected override bool AutoStartObserver => false; - - [TestMethod] - public async Task PeriodicPolling_DoesNotRunParallelReviewForSameFile() - { - const string relativePath = "src/RepeatedPolling.cs"; - var absolutePath = AbsolutePath(relativePath); - - await CreateCommittedFileAsync(relativePath, "public class RepeatedPolling { public int Value => 1; }", "Add repeated polling file"); - CheckoutBranch("feature", create: true); - await WriteWorkingFileAsync(relativePath, "public class RepeatedPolling { public int Value => 2; }"); - CommitAll("Change repeated polling file"); - - var block = CodeReviewer.BlockNextReview(absolutePath); - try - { - await StartObserverAsync(); - await WaitForConditionAsync(() => block.Entered.IsCompleted, "The periodic scan never reached the blocked review."); - await Task.Delay(1500); - SnapshotState("while-review-blocked", relativePath); - - Assert.AreEqual(1, ReviewCount(relativePath), Journal.Dump()); - Assert.AreEqual(1, MaxParallelReviews(relativePath), Journal.Dump()); - - block.Release(); - await WaitForReviewCountAsync(relativePath, 1); - } - finally - { - block.Release(); - } - } - - [TestMethod] - public async Task StartupRace_ChangeBeforeStart_IsObservedAfterStartup() - { - const string relativePath = "src/StartupObserved.cs"; - - await WriteWorkingFileAsync(relativePath, "public class StartupObserved { public int Value => 1; }"); - - await StartObserverAsync(); - await WaitForTrackedAsync(relativePath); - await WaitForReviewCountAsync(relativePath, 1); - SnapshotState("post-startup-race", relativePath); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingAsyncTaskScheduler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingAsyncTaskScheduler.cs deleted file mode 100644 index 7412341f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingAsyncTaskScheduler.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Interfaces; - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -public sealed class RecordingAsyncTaskScheduler : IAsyncTaskScheduler, IDisposable -{ - private readonly ConcurrentDictionary _pending = new ConcurrentDictionary(); - private readonly EventJournal _journal; - private int _nextId; - private bool _disposed; - - public RecordingAsyncTaskScheduler(EventJournal journal) - { - _journal = journal; - } - - public void Schedule(Func asyncWork) - { - Schedule(_ => asyncWork(), CancellationToken.None); - } - - public void Schedule(Func asyncWork) - { - Schedule(asyncWork, CancellationToken.None); - } - - public void Schedule(Func asyncWork, CancellationToken cancellationToken) - { - if (_disposed) - { - return; - } - - var workId = Interlocked.Increment(ref _nextId); - _journal.Record("scheduler.queued", detail: $"id={workId}"); - var task = Task.Run( - async () => - { - _journal.Record("scheduler.started", detail: $"id={workId}"); - try - { - await asyncWork(cancellationToken); - _journal.Record("scheduler.completed", detail: $"id={workId}"); - } - catch (OperationCanceledException) - { - _journal.Record("scheduler.canceled", detail: $"id={workId}"); - } - catch (Exception ex) - { - _journal.Record("scheduler.failed", detail: $"id={workId};{ex.Message}"); - throw; - } - finally - { - _pending.TryRemove(workId, out _); - } - }, - CancellationToken.None); - - _pending[workId] = task; - } - - public async Task WaitForIdleAsync(int timeoutMs) - { - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - if (_pending.IsEmpty) - { - return; - } - - await Task.Delay(50); - } - } - - public void Dispose() - { - _disposed = true; - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCliExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCliExecutor.cs deleted file mode 100644 index dc99dade..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCliExecutor.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Refactor; -using Codescene.VSExtension.Core.Models.Cli.Review; - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -public sealed class RecordingCliExecutor : ICliExecutor -{ - private readonly ICliExecutor _inner; - private readonly EventJournal _journal; - private readonly ConcurrentDictionary _deltaBlocks = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public RecordingCliExecutor(ICliExecutor inner, EventJournal journal) - { - _inner = inner; - _journal = journal; - } - - public ReviewBlockHandle BlockNextDelta(string path) - { - var handle = new ReviewBlockHandle(path); - _deltaBlocks[path] = handle; - return handle; - } - - public async Task ReviewDeltaAsync(ReviewDeltaRequest request, CancellationToken cancellationToken = default) - { - _journal.Record("cli.delta.started", request?.FilePath); - - if (request?.FilePath != null && _deltaBlocks.TryRemove(request.FilePath, out var block)) - { - block.MarkEntered(); - await block.WaitForReleaseAsync(cancellationToken); - } - - try - { - var result = await _inner.ReviewDeltaAsync(request, cancellationToken); - _journal.Record("cli.delta.completed", request?.FilePath, $"result={result != null}"); - return result!; - } - catch (OperationCanceledException) - { - _journal.Record("cli.delta.canceled", request?.FilePath); - throw; - } - } - - public async Task ReviewContentAsync(string filePath, string content, bool isBaseLine = false, CancellationToken cancellationToken = default) - { - _journal.Record("cli.review.started", filePath, $"baseline={isBaseLine}"); - try - { - var result = await _inner.ReviewContentAsync(filePath, content, isBaseLine, cancellationToken); - _journal.Record("cli.review.completed", filePath, $"result={result != null};baseline={isBaseLine}"); - return result!; - } - catch (OperationCanceledException) - { - _journal.Record("cli.review.canceled", filePath, $"baseline={isBaseLine}"); - throw; - } - } - - public Task GetFileVersionAsync(CancellationToken cancellationToken = default) - { - return _inner.GetFileVersionAsync(cancellationToken); - } - - public Task GetDeviceIdAsync(CancellationToken cancellationToken = default) - { - return _inner.GetDeviceIdAsync(cancellationToken); - } - - public Task PreflightAsync(bool force = true, CancellationToken cancellationToken = default) - { - return _inner.PreflightAsync(force, cancellationToken); - } - - public Task PostRefactoringAsync(FnToRefactorModel fnToRefactor, bool skipCache = false, string? token = null, CancellationToken cancellationToken = default) - { - return _inner.PostRefactoringAsync(fnToRefactor, skipCache, token, cancellationToken); - } - - public Task> FnsToRefactorFromCodeSmellsAsync(string fileName, string fileContent, IList codeSmells, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) - { - return _inner.FnsToRefactorFromCodeSmellsAsync(fileName, fileContent, codeSmells, preflight, cancellationToken); - } - - public Task> FnsToRefactorFromDeltaAsync(string fileName, string fileContent, DeltaResponseModel deltaResponse, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) - { - return _inner.FnsToRefactorFromDeltaAsync(fileName, fileContent, deltaResponse, preflight, cancellationToken); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCodeReviewer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCodeReviewer.cs deleted file mode 100644 index 68d6762a..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCodeReviewer.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cli.Delta; - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -public sealed class RecordingCodeReviewer : ICodeReviewer -{ - private readonly ICodeReviewer _inner; - private readonly EventJournal _journal; - private readonly ConcurrentDictionary _reviewWithDeltaCalls = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _activeReviewWithDeltaCalls = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _maxParallelism = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _blocks = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public RecordingCodeReviewer(ICodeReviewer inner, EventJournal journal) - { - _inner = inner; - _journal = journal; - } - - public ReviewBlockHandle BlockNextReview(string path) - { - var handle = new ReviewBlockHandle(path); - _blocks[path] = handle; - return handle; - } - - public int GetReviewWithDeltaCallCount(string path) - { - return _reviewWithDeltaCalls.TryGetValue(path, out var count) ? count : 0; - } - - public int GetMaxParallelism(string path) - { - return _maxParallelism.TryGetValue(path, out var count) ? count : 0; - } - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - return _inner.ReviewAsync(path, content, isBaseline, operationGeneration, cancellationToken); - } - - public Task DeltaAsync(FileReviewModel review, string currentCode, string? precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string? baselineCommit = null) - { - return _inner.DeltaAsync(review, currentCode, precomputedBaselineRawScore, operationGeneration, cancellationToken, baselineCommit); - } - - public Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string? baselineCommit = null) - { - return _inner.ReviewAndBaselineAsync(path, currentCode, operationGeneration, cancellationToken, baselineCommit); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string? baselineCommit = null) - { - _reviewWithDeltaCalls.AddOrUpdate(path, 1, (_, count) => count + 1); - var activeCount = _activeReviewWithDeltaCalls.AddOrUpdate(path, 1, (_, count) => count + 1); - _maxParallelism.AddOrUpdate(path, activeCount, (_, currentMax) => Math.Max(currentMax, activeCount)); - _journal.Record("review.started", path, $"active={activeCount}"); - - if (_blocks.TryRemove(path, out var block)) - { - block.MarkEntered(); - await block.WaitForReleaseAsync(cancellationToken); - } - - try - { - var result = await _inner.ReviewWithDeltaAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - _journal.Record("review.completed", path, $"delta={result.delta != null}"); - return result; - } - catch (OperationCanceledException) - { - _journal.Record("review.canceled", path); - throw; - } - finally - { - _activeReviewWithDeltaCalls.AddOrUpdate(path, 0, (_, count) => Math.Max(0, count - 1)); - } - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string? baselineCommit = null) - { - return _inner.GetOrComputeBaselineRawScoreAsync(path, baselineContent, operationGeneration, cancellationToken, baselineCommit); - } -} - -public sealed class ReviewBlockHandle -{ - private readonly TaskCompletionSource _entered = new TaskCompletionSource(); - private readonly TaskCompletionSource _released = new TaskCompletionSource(); - - public ReviewBlockHandle(string path) - { - Path = path; - } - - public string Path { get; } - - public Task Entered => _entered.Task; - - public void MarkEntered() - { - _entered.TrySetResult(true); - } - - public void Release() - { - _released.TrySetResult(true); - } - - public async Task WaitForReleaseAsync(CancellationToken cancellationToken) - { - using (cancellationToken.Register(() => _released.TrySetCanceled())) - { - await _released.Task; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingGitObservers.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingGitObservers.cs deleted file mode 100644 index 7e24f4e9..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingGitObservers.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; - -namespace Codescene.VSExtension.Core.SubcutaneousTests; - -public sealed class RecordingGitChangeLister : IGitChangeLister, IDisposable -{ - private readonly IGitChangeLister _inner; - private readonly EventJournal _journal; - - public RecordingGitChangeLister(IGitChangeLister inner, EventJournal journal) - { - _inner = inner; - _journal = journal; - _inner.FilesDetected += OnFilesDetected; - } - - public event EventHandler>? FilesDetected; - - public async Task> GetAllChangedFilesAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - _journal.Record("lister.changed-files.started"); - var files = await _inner.GetAllChangedFilesAsync(gitRootPath, workspacePath, cancellationToken); - _journal.Record("lister.changed-files.completed", detail: $"count={files.Count}"); - return files; - } - - public Task> GetChangedFilesVsMergeBaseAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - return _inner.GetChangedFilesVsMergeBaseAsync(gitRootPath, workspacePath, cancellationToken); - } - - public void Initialize(string gitRootPath, IReadOnlyCollection workspacePaths) - { - _journal.Record("lister.initialize", gitRootPath, $"workspaceCount={workspacePaths.Count}"); - _inner.Initialize(gitRootPath, workspacePaths); - } - - public void SetWorkspacePaths(IReadOnlyCollection workspacePaths) - { - _inner.SetWorkspacePaths(workspacePaths); - } - - public void StartPeriodicScanning(CancellationToken cancellationToken) - { - _journal.Record("lister.periodic.start"); - _inner.StartPeriodicScanning(cancellationToken); - } - - public void StopPeriodicScanning() - { - _journal.Record("lister.periodic.stop"); - _inner.StopPeriodicScanning(); - } - - public async Task> CollectFilesFromRepoStateAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - _journal.Record("lister.collect.started"); - var files = await _inner.CollectFilesFromRepoStateAsync(gitRootPath, workspacePaths, cancellationToken); - _journal.Record("lister.collect.completed", detail: $"count={files.Count}"); - return files; - } - - public void Dispose() - { - _inner.FilesDetected -= OnFilesDetected; - if (_inner is IDisposable disposable) - { - disposable.Dispose(); - } - } - - private void OnFilesDetected(object? sender, HashSet files) - { - _journal.Record("lister.files-detected", detail: $"count={files.Count}"); - FilesDetected?.Invoke(this, files); - } -} - -public sealed class RecordingGitChangeObserverCore : GitChangeObserverCore -{ - private readonly EventJournal _journal; - - public RecordingGitChangeObserverCore( - ILogger logger, - ICodeReviewer codeReviewer, - ISupportedFileChecker supportedFileChecker, - IAsyncTaskScheduler taskScheduler, - IGitChangeLister gitChangeLister, - IGitService gitService, - EventJournal journal) - : base(logger, codeReviewer, supportedFileChecker, taskScheduler, gitChangeLister, gitService) - { - _journal = journal; - } - - public override async Task> GetChangedFilesVsBaselineAsync(string baselineCommit) - { - _journal.Record("observer.changed-files.started"); - var files = await base.GetChangedFilesVsBaselineAsync(baselineCommit); - _journal.Record("observer.changed-files.completed", detail: $"count={files.Count}"); - return files; - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs index 367407f3..d3a0a6d8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs @@ -1,12 +1,8 @@ // Copyright (c) CodeScene. All rights reserved. using System.Diagnostics; -using System.Reflection; using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Application.Services; -using Codescene.VSExtension.Core.Interfaces.Cli; using Codescene.VSExtension.Core.Util; using Codescene.VSExtension.VS2022.Application.Git; @@ -18,51 +14,21 @@ public abstract class SubcutaneousGitTestBase protected TestLogger Logger { get; private set; } = null!; - protected RecordingAsyncTaskScheduler TaskScheduler { get; private set; } = null!; - - protected TestSavedFilesTracker SavedFilesTracker { get; private set; } = null!; - - protected TestOpenFilesObserver OpenFilesObserver { get; private set; } = null!; - - protected TestSupportedFileChecker SupportedFileChecker { get; private set; } = null!; - - protected RecordingCliExecutor CliExecutor { get; private set; } = null!; - - protected RecordingCodeReviewer CodeReviewer { get; private set; } = null!; - - protected RecordingGitChangeLister GitChangeLister { get; private set; } = null!; - - protected RecordingGitChangeObserverCore Observer { get; private set; } = null!; - protected GitService GitService { get; private set; } = null!; - protected DeltaCacheService DeltaCache { get; private set; } = null!; - protected string RepositoryRoot { get; private set; } = string.Empty; protected string CacheDirectory { get; private set; } = string.Empty; - protected virtual int GitPollingIntervalSeconds => 1; - - protected virtual bool AutoStartObserver => true; - protected virtual int DefaultTimeoutMs => 30000; - protected virtual int ConditionPollIntervalMs => 100; - [TestInitialize] - public virtual async Task InitializeAsync() + public virtual Task InitializeAsync() { CacheGeneration.Reset(); DeltaJobTracker.Clear(); - MainBranchNames.ClearDefaultBranchCache(); Journal = new EventJournal(); Logger = new TestLogger(Journal); - TaskScheduler = new RecordingAsyncTaskScheduler(Journal); - SavedFilesTracker = new TestSavedFilesTracker(Journal); - OpenFilesObserver = new TestOpenFilesObserver(); - SupportedFileChecker = new TestSupportedFileChecker(); - DeltaCache = new DeltaCacheService(); RepositoryRoot = Path.Combine(Path.GetTempPath(), "codescene-subcutaneous-tests", Guid.NewGuid().ToString("N")); CacheDirectory = Path.Combine(RepositoryRoot, ".cache"); @@ -72,68 +38,16 @@ public virtual async Task InitializeAsync() InitializeGitRepository(); GitService = new GitService(Logger); - CliExecutor = new RecordingCliExecutor(CreateCliExecutor(), Journal); - - var notifier = new CodeHealthMonitorNotifier(); - notifier.ViewUpdateRequested += (_, _) => Journal.Record("notifier.view-update"); - - var innerReviewer = new CodeReviewer( - Logger, - new ModelMapper(), - CliExecutor, - null, - GitService, - notifier, - null); - - var cachingReviewer = new CachingCodeReviewer( - innerReviewer, - logger: Logger, - git: GitService, - notifier: notifier, - deltaCache: DeltaCache); - - CodeReviewer = new RecordingCodeReviewer(cachingReviewer, Journal); - GitChangeLister = new RecordingGitChangeLister( - new GitChangeLister(SavedFilesTracker, SupportedFileChecker, Logger, GitService, pollingInterval: GitPollingIntervalSeconds), - Journal); - - Observer = new RecordingGitChangeObserverCore( - Logger, - CodeReviewer, - SupportedFileChecker, - TaskScheduler, - GitChangeLister, - GitService, - Journal); - - Observer.FileDeletedFromGit += (_, path) => Journal.Record("observer.file-deleted", path); - Observer.ViewUpdateRequested += (_, _) => Journal.Record("observer.view-update"); - Observer.Initialize(RepositoryRoot, SavedFilesTracker, OpenFilesObserver); - - if (AutoStartObserver) - { - await StartObserverAsync(); - } + return Task.CompletedTask; } [TestCleanup] - public virtual async Task CleanupAsync() + public virtual Task CleanupAsync() { - if (TaskScheduler != null) - { - await TaskScheduler.WaitForIdleAsync(DefaultTimeoutMs); - } - - Observer?.Dispose(); - GitChangeLister?.Dispose(); GitService?.Dispose(); - TaskScheduler?.Dispose(); if (!string.IsNullOrEmpty(RepositoryRoot) && Directory.Exists(RepositoryRoot)) { - ReviewCacheCleanup.CleanupCaches(RepositoryRoot); - try { Directory.Delete(RepositoryRoot, recursive: true); @@ -145,6 +59,7 @@ public virtual async Task CleanupAsync() CacheGeneration.Reset(); DeltaJobTracker.Clear(); + return Task.CompletedTask; } protected string AbsolutePath(string relativePath) @@ -152,15 +67,7 @@ protected string AbsolutePath(string relativePath) return Path.Combine(RepositoryRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); } - protected async Task StartObserverAsync() - { - Observer.Start(); - await WaitForConditionAsync( - () => Observer.FileWatcher != null && Observer.FileWatcher.EnableRaisingEvents, - "The git observer did not finish starting."); - } - - protected Task WriteWorkingFileAsync(string relativePath, string content, bool markSaved = false) + protected Task WriteWorkingFileAsync(string relativePath, string content) { var absolutePath = AbsolutePath(relativePath); var directory = Path.GetDirectoryName(absolutePath); @@ -170,45 +77,16 @@ protected Task WriteWorkingFileAsync(string relativePath, string content } System.IO.File.WriteAllText(absolutePath, content); - if (markSaved) - { - SavedFilesTracker.MarkSaved(absolutePath); - } - Journal.Record("stimulus.file-write", absolutePath, $"length={content.Length}"); return Task.FromResult(absolutePath); } - protected Task AppendWorkingFileAsync(string relativePath, string content) - { - var absolutePath = AbsolutePath(relativePath); - System.IO.File.AppendAllText(absolutePath, content); - Journal.Record("stimulus.file-append", absolutePath, $"length={content.Length}"); - return Task.CompletedTask; - } - - protected void DeleteWorkingFile(string relativePath) - { - var absolutePath = AbsolutePath(relativePath); - if (System.IO.File.Exists(absolutePath)) - { - System.IO.File.Delete(absolutePath); - } - - Journal.Record("stimulus.file-delete", absolutePath); - } - protected void ExecGit(string arguments) { var result = RunGitCommand(arguments); EnsureGitSucceeded(arguments, result); } - protected GitCommandResult ExecGitAllowFailure(string arguments) - { - return RunGitCommand(arguments); - } - protected void CommitAll(string message) { ExecGit("add -A"); @@ -228,32 +106,6 @@ protected void CheckoutBranch(string branchName, bool create = false) ExecGit(create ? $"checkout -b {branchName}" : $"checkout {branchName}"); } - protected void ResetHard(string target) - { - ExecGit($"reset --hard {target}"); - } - - protected void StashPush(string name, bool includeUntracked = false) - { - var includeUntrackedArg = includeUntracked ? " -u" : string.Empty; - ExecGit($"stash push{includeUntrackedArg} -m \"{name}\""); - } - - protected void StashPop() - { - ExecGit("stash pop"); - } - - protected GitCommandResult StashPopAllowFailure() - { - return ExecGitAllowFailure("stash pop"); - } - - protected void RebaseOnto(string target) - { - ExecGit($"rebase {target}"); - } - protected void SetOriginHeadToBranch(string branchName) { ExecGit($"update-ref refs/remotes/origin/{branchName} refs/heads/{branchName}"); @@ -270,99 +122,6 @@ protected void WriteCodesceneConfig(string baselineBranch) Journal.Record("stimulus.config-write", configPath, $"baseline_branch={baselineBranch}"); } - protected bool IsTracked(string relativePath) - { - return Observer.GetTrackerManager().Contains(AbsolutePath(relativePath)); - } - - protected bool HasDelta(string relativePath) - { - var abs = AbsolutePath(relativePath); - return DeltaCache.GetAll().Keys.Any(k => string.Equals(k, abs, StringComparison.OrdinalIgnoreCase)); - } - - protected int ReviewCount(string relativePath) - { - return CodeReviewer.GetReviewWithDeltaCallCount(AbsolutePath(relativePath)); - } - - protected int MaxParallelReviews(string relativePath) - { - return CodeReviewer.GetMaxParallelism(AbsolutePath(relativePath)); - } - - protected int RunningDeltaJobCount() - { - return DeltaJobTracker.RunningJobs.Count; - } - - protected bool HasRunningDeltaJob(string relativePath) - { - var absolutePath = AbsolutePath(relativePath); - return DeltaJobTracker.RunningJobs.Any(job => string.Equals(job.File?.FileName, absolutePath, StringComparison.OrdinalIgnoreCase)); - } - - protected IReadOnlyCollection DeltaCachePaths() - { - return DeltaCache.GetAll().Keys.ToList().AsReadOnly(); - } - - protected async Task WaitForTrackedAsync(string relativePath, string? message = null, int timeoutMs = 30000) - { - await WaitForConditionAsync(() => IsTracked(relativePath), message ?? $"Expected {relativePath} to be tracked.", timeoutMs); - } - - protected async Task WaitForNotTrackedAsync(string relativePath, string? message = null, int timeoutMs = 30000) - { - await WaitForConditionAsync(() => !IsTracked(relativePath), message ?? $"Expected {relativePath} to be removed from the tracker.", timeoutMs); - } - - protected async Task WaitForDeltaAsync(string relativePath, string? message = null, int timeoutMs = 30000) - { - await WaitForConditionAsync(() => HasDelta(relativePath), message ?? $"Expected {relativePath} to produce a delta result.", timeoutMs); - } - - protected async Task WaitForNoDeltaAsync(string relativePath, string? message = null, int timeoutMs = 30000) - { - await WaitForConditionAsync(() => !HasDelta(relativePath), message ?? $"Expected {relativePath} to be absent from the delta cache.", timeoutMs); - } - - protected async Task WaitForReviewCountAsync(string relativePath, int expectedCount, string? message = null, int timeoutMs = 30000) - { - await WaitForConditionAsync( - () => ReviewCount(relativePath) >= expectedCount, - message ?? $"Expected at least {expectedCount} review(s) for {relativePath}.", - timeoutMs); - } - - protected void SnapshotState(string label, params string[] relativePaths) - { - foreach (var relativePath in relativePaths) - { - var absolutePath = AbsolutePath(relativePath); - Journal.Record( - "state.snapshot", - absolutePath, - $"{label};tracked={Observer.GetTrackerManager().Contains(absolutePath)};delta={HasDelta(relativePath)};reviewCount={CodeReviewer.GetReviewWithDeltaCallCount(absolutePath)}"); - } - } - - protected async Task WaitForConditionAsync(Func condition, string failureMessage, int timeoutMs = 30000) - { - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - if (condition()) - { - return; - } - - await Task.Delay(ConditionPollIntervalMs); - } - - Assert.Fail($"{failureMessage}{Environment.NewLine}{Environment.NewLine}{Journal.Dump()}"); - } - private GitCommandResult RunGitCommand(string arguments) { Journal.Record("stimulus.git", detail: arguments); @@ -394,32 +153,6 @@ private void EnsureGitSucceeded(string arguments, GitCommandResult result) } } - private CliExecutor CreateCliExecutor() - { - var settingsProvider = new TestSettingsProvider(); - var cliSettingsProvider = new CliSettingsProvider(); - var commandProvider = new CliCommandProvider(new CliObjectScoreCreator(Logger)); - var processExecutor = CreateProcessExecutor(cliSettingsProvider); - var cliServices = new TestCliServices(commandProvider, processExecutor, new TestCacheStorageService(CacheDirectory)); - return new CliExecutor(Logger, cliServices, settingsProvider, null); - } - - private IProcessExecutor CreateProcessExecutor(ICliSettingsProvider cliSettingsProvider) - { - var processExecutorType = typeof(CliSettingsProvider).Assembly.GetType( - "Codescene.VSExtension.Core.Application.Cli.ProcessExecutor", - throwOnError: true); - - var processExecutor = Activator.CreateInstance( - processExecutorType!, - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, - null, - new object[] { cliSettingsProvider, Logger }, - null); - - return (IProcessExecutor)processExecutor!; - } - private void InitializeGitRepository() { ExecGit("init"); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs index dcca3580..b15af738 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs @@ -35,64 +35,6 @@ public void Warn(string message, bool statusBar = false) } } -public sealed class TestSavedFilesTracker : ISavedFilesTracker -{ - private readonly HashSet _savedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); - private readonly EventJournal _journal; - - public TestSavedFilesTracker(EventJournal journal) - { - _journal = journal; - } - - public IEnumerable GetSavedFiles() - { - lock (_savedFiles) - { - return _savedFiles.ToArray(); - } - } - - public void ClearSavedFiles() - { - lock (_savedFiles) - { - _savedFiles.Clear(); - } - } - - public void RemoveFromTracker(string filePath) - { - lock (_savedFiles) - { - _savedFiles.Remove(filePath); - } - } - - public void MarkSaved(string filePath) - { - lock (_savedFiles) - { - _savedFiles.Add(filePath); - } - - _journal.Record("saved-file.added", filePath); - } -} - -public sealed class TestOpenFilesObserver : IOpenFilesObserver -{ - public IEnumerable GetAllVisibleFileNames() - { - return Array.Empty(); - } - - public string GetActiveDocumentPath() - { - return string.Empty; - } -} - public sealed class TestSupportedFileChecker : ISupportedFileChecker { private static readonly HashSet SupportedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -111,22 +53,6 @@ public bool IsSupported(string filePath) } } -public sealed class TestCliServices : ICliServices -{ - public TestCliServices(ICliCommandProvider commandProvider, IProcessExecutor processExecutor, ICacheStorageService cacheStorage) - { - CommandProvider = commandProvider; - ProcessExecutor = processExecutor; - CacheStorage = cacheStorage; - } - - public ICliCommandProvider CommandProvider { get; } - - public IProcessExecutor ProcessExecutor { get; } - - public ICacheStorageService CacheStorage { get; } -} - public sealed class TestCacheStorageService : ICacheStorageService { private readonly string _cacheDirectory; @@ -162,4 +88,10 @@ public sealed class TestSettingsProvider : ISettingsProvider public bool ShowDebugLogs => false; public string AuthToken => string.Empty; + + public int ServerWorkerThreads => 0; + + public bool EnableAutomaticAnalysis => true; + + public bool EnableTelemetry => true; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/WatchWorkflowSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/WatchWorkflowSubcutaneousTests.cs new file mode 100644 index 00000000..0f2b3508 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/WatchWorkflowSubcutaneousTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.SubcutaneousTests; + +[TestClass] +public class WatchWorkflowSubcutaneousTests : SubcutaneousGitTestBase +{ + [TestMethod] + public async Task WatchInventory_PrunesStaleMonitorEntriesAndKeepsDirtyBuffers() + { + var dirtyPath = AbsolutePath("src/dirty.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(dirtyPath)); + File.WriteAllText(dirtyPath, "class Dirty {}"); + File.WriteAllText(AbsolutePath("src/stale.cs"), "class Stale {}"); + + var client = new Mock(); + var pipeline = new Mock(); + ISet keep = null; + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = dirtyPath, Content = "class Dirty {}", IsDirty = true }, + }); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback, ISet>((inventory, paths) => keep = paths); + + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, Logger, host.Object)) + { + await coordinator.SyncAsync(new[] { RepositoryRoot }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = RepositoryRoot, Files = new[] { "src/tracked.cs" } }); + + Assert.IsNotNull(keep); + Assert.IsTrue(keep.Any(path => path.EndsWith("tracked.cs", StringComparison.OrdinalIgnoreCase))); + Assert.Contains(dirtyPath, keep); + Assert.IsFalse(keep.Any(path => path.EndsWith("stale.cs", StringComparison.OrdinalIgnoreCase))); + } + } + + [TestMethod] + public async Task IdLessWatchReview_PresentsWhenDocumentIsOnDisk() + { + var filePath = await WriteWorkingFileAsync("src/watched.cs", "class Watched {}"); + var sha = GitBlobSha.FromBytes(File.ReadAllBytes(filePath)); + var client = new Mock(); + var events = new List(); + var presentation = new Mock(); + presentation.Setup(x => x.PresentReview(It.IsAny())) + .Callback(events.Add); + + using (var pipeline = new ReviewPipeline(client.Object, presentation.Object, null, Logger)) + { + client.Raise( + x => x.ReviewReceived += null, + client.Object, + new ReviewNotification + { + RepoRoot = RepositoryRoot, + Path = "src/watched.cs", + Result = new CliReviewModel + { + FileLevelCodeSmells = new List(), + FunctionLevelCodeSmells = new List(), + RawScore = "raw", + GitBlobSha = sha, + }, + }); + + for (var i = 0; i < 40 && events.Count == 0; i++) + { + await Task.Delay(10); + } + } + + Assert.HasCount(1, events); + Assert.AreEqual(filePath, events[0].Document.FilePath); + Assert.IsTrue(events[0].UpdateMonitor); + Assert.IsFalse(events[0].UpdateDiagnosticsPane); + } + + [TestMethod] + public async Task IdLessWatchDelta_PresentsDiskDocument() + { + var filePath = await WriteWorkingFileAsync("src/watched.cs", "class Watched {}"); + var sha = GitBlobSha.FromBytes(File.ReadAllBytes(filePath)); + var client = new Mock(); + var events = new List(); + var presentation = new Mock(); + presentation.Setup(x => x.PresentDelta(It.IsAny())) + .Callback(events.Add); + + using (var pipeline = new ReviewPipeline(client.Object, presentation.Object, null, Logger)) + { + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification + { + RepoRoot = RepositoryRoot, + Path = "src/watched.cs", + Result = new DeltaResponseModel + { + NewGitBlobSha = sha, + ScoreChange = 0.1m, + }, + }); + + for (var i = 0; i < 40 && events.Count == 0; i++) + { + await Task.Delay(10); + } + } + + Assert.HasCount(1, events); + Assert.IsTrue(events[0].UpdateMonitor); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/AceManagerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/AceManagerTests.cs index 6ba05eed..086283d9 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/AceManagerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/AceManagerTests.cs @@ -138,6 +138,25 @@ public async Task Refactor_WhenExceptionThrown_LogsErrorSetsErrorAndRethrows() _mockAceStateService.Verify(s => s.SetError(expectedException), Times.Once); } + [TestMethod] + public async Task RefactorAsync_WhenCancelled_DoesNotSetErrorOrLogError() + { + var path = "test.cs"; + var fnToRefactor = new FnToRefactorModel { Name = "TestMethod" }; + var entryPoint = "toolbar"; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + _mockExecutor.Setup(x => x.PostRefactoringAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellation.Token)); + + await Assert.ThrowsAsync(() => + _aceManager.RefactorAsync(path, fnToRefactor, entryPoint, cancellationToken: cancellation.Token)); + + _mockAceStateService.Verify(s => s.SetError(It.IsAny()), Times.Never); + _mockLogger.Verify(l => l.Error(It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] public void GetCachedRefactoredCode_WhenNoCachedResult_ReturnsNull() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BaselineReviewCacheServiceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BaselineReviewCacheServiceTests.cs deleted file mode 100644 index 1e834c5d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BaselineReviewCacheServiceTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class BaselineReviewCacheServiceTests - { - private BaselineReviewCacheService _cache; - - [TestInitialize] - public void Setup() - { - _cache = new BaselineReviewCacheService(new ConcurrentDictionary()); - } - - [TestMethod] - public void Get_WithNullFilePath_ReturnsNotFound() - { - var (found, rawScore) = _cache.Get(null, "content"); - - Assert.IsFalse(found); - Assert.IsNull(rawScore); - } - - [TestMethod] - public void Get_WithEmptyFilePath_ReturnsNotFound() - { - var (found, rawScore) = _cache.Get(string.Empty, "content"); - - Assert.IsFalse(found); - Assert.IsNull(rawScore); - } - - [TestMethod] - public void Get_WithNullBaselineContent_ReturnsNotFound() - { - var (found, rawScore) = _cache.Get("file.cs", null); - - Assert.IsFalse(found); - Assert.IsNull(rawScore); - } - - [TestMethod] - public void Get_WithEmptyBaselineContent_ReturnsNotFound() - { - var (found, rawScore) = _cache.Get("file.cs", string.Empty); - - Assert.IsFalse(found); - Assert.IsNull(rawScore); - } - - [TestMethod] - public void Get_WhenCached_ReturnsScore() - { - _cache.Put("file.cs", "content", "raw-score"); - - var (found, rawScore) = _cache.Get("file.cs", "content"); - - Assert.IsTrue(found); - Assert.AreEqual("raw-score", rawScore); - } - - [TestMethod] - public void Get_WhenNotCached_ReturnsNotFound() - { - var (found, rawScore) = _cache.Get("file.cs", "content"); - - Assert.IsFalse(found); - Assert.IsNull(rawScore); - } - - [TestMethod] - public void Put_WithValidInput_StoresInCache() - { - _cache.Put("file.cs", "content", "raw-score"); - - var (found, rawScore) = _cache.Get("file.cs", "content"); - - Assert.IsTrue(found); - Assert.AreEqual("raw-score", rawScore); - } - - [TestMethod] - public void Put_WithNullFilePath_DoesNotStore() - { - _cache.Put(null, "content", "raw-score"); - - var (found, _) = _cache.Get("file.cs", "content"); - - Assert.IsFalse(found); - } - - [TestMethod] - public void Put_WithEmptyFilePath_DoesNotStore() - { - _cache.Put(string.Empty, "content", "raw-score"); - - var (found, _) = _cache.Get("file.cs", "content"); - - Assert.IsFalse(found); - } - - [TestMethod] - public void Put_WithNullBaselineContent_DoesNotStore() - { - _cache.Put("file.cs", null, "raw-score"); - - var (found, _) = _cache.Get("file.cs", "content"); - - Assert.IsFalse(found); - } - - [TestMethod] - public void Put_WithEmptyBaselineContent_DoesNotStore() - { - _cache.Put("file.cs", string.Empty, "raw-score"); - - var (found, _) = _cache.Get("file.cs", "content"); - - Assert.IsFalse(found); - } - - [TestMethod] - public void Put_WithNullRawScore_StoresEmptyString() - { - _cache.Put("file.cs", "content", null); - - var (found, rawScore) = _cache.Get("file.cs", "content"); - - Assert.IsTrue(found); - Assert.AreEqual(string.Empty, rawScore); - } - - [TestMethod] - public void Clear_RemovesAllEntries() - { - _cache.Put("file.cs", "content", "raw-score"); - - _cache.Clear(); - - var (found, _) = _cache.Get("file.cs", "content"); - Assert.IsFalse(found); - } - - [TestMethod] - public void Invalidate_WithNullFilePath_DoesNothing() - { - _cache.Put("file.cs", "content", "raw-score"); - - _cache.Invalidate(null); - - var (found, _) = _cache.Get("file.cs", "content"); - Assert.IsTrue(found); - } - - [TestMethod] - public void Invalidate_WithEmptyFilePath_DoesNothing() - { - _cache.Put("file.cs", "content", "raw-score"); - - _cache.Invalidate(string.Empty); - - var (found, _) = _cache.Get("file.cs", "content"); - Assert.IsTrue(found); - } - - [TestMethod] - public void Invalidate_WithMatchingFilePath_RemovesEntries() - { - _cache.Put("path/file.cs", "content1", "score1"); - _cache.Put("path/file.cs", "content2", "score2"); - _cache.Put("path/file.csx", "contentX", "scoreX"); - _cache.Put("other/file.cs", "content3", "score3"); - - _cache.Invalidate("path/file.cs"); - - var (found1, _) = _cache.Get("path/file.cs", "content1"); - var (found2, _) = _cache.Get("path/file.cs", "content2"); - var (foundX, rawX) = _cache.Get("path/file.csx", "contentX"); - var (found3, raw3) = _cache.Get("other/file.cs", "content3"); - Assert.IsFalse(found1); - Assert.IsFalse(found2); - Assert.IsTrue(foundX); - Assert.AreEqual("scoreX", rawX); - Assert.IsTrue(found3); - Assert.AreEqual("score3", raw3); - } - - [TestMethod] - public void RemoveEntriesOutsideRoot_NullOrEmptyRoot_DoesNothing() - { - _cache.Put("file.cs", "content", "score"); - _cache.RemoveEntriesOutsideRoot(null); - var (found, _) = _cache.Get("file.cs", "content"); - Assert.IsTrue(found); - _cache.RemoveEntriesOutsideRoot(string.Empty); - var (found2, _) = _cache.Get("file.cs", "content"); - Assert.IsTrue(found2); - } - - [TestMethod] - public void RemoveEntriesOutsideRoot_RemovesEntriesOutsideRoot() - { - var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "baseline-cache-root")); - var insidePath = Path.Combine(root, "sub", "file.cs"); - var outsidePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "other", "file.cs")); - _cache.Put(insidePath, "c1", "s1"); - _cache.Put(outsidePath, "c2", "s2"); - _cache.RemoveEntriesOutsideRoot(root); - var (foundInside, _) = _cache.Get(insidePath, "c1"); - var (foundOutside, _) = _cache.Get(outsidePath, "c2"); - Assert.IsTrue(foundInside); - Assert.IsFalse(foundOutside); - } - - [TestMethod] - public void RemoveEntriesOutsideRoot_WhenGetFullPathThrows_RemovesKey() - { - var store = new ConcurrentDictionary(); - var key = "\0|abc123"; - store[key] = "score"; - var service = new BaselineReviewCacheService(store); - service.RemoveEntriesOutsideRoot(Path.GetTempPath()); - Assert.IsFalse(store.ContainsKey(key)); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BatchGitIgnoreCheckerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BatchGitIgnoreCheckerTests.cs deleted file mode 100644 index 9c1e316c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BatchGitIgnoreCheckerTests.cs +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Git; -using LibGit2Sharp; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class BatchGitIgnoreCheckerTests - { - private string _testRepoPath = null!; - private Mock _mockLogger = null!; - private TestBatchGitIgnoreChecker _checker = null!; - - [TestInitialize] - public void Setup() - { - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-repo-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - _mockLogger = new Mock(); - _checker = new TestBatchGitIgnoreChecker(_mockLogger.Object); - } - - [TestCleanup] - public void Cleanup() - { - _checker = null!; - if (Directory.Exists(_testRepoPath)) - { - try - { - ForceDeleteDirectory(_testRepoPath); - } - catch - { - } - } - } - - [TestMethod] - public void FilterIgnored_EmptyInput_ReturnsEmptySet() - { - var result = _checker.FilterIgnored(Array.Empty()); - - Assert.IsEmpty(result); - } - - [TestMethod] - public void FilterIgnored_SingleNonIgnoredFile_ReturnsFile() - { - var filePath = Path.Combine(_testRepoPath, "test.cs"); - File.WriteAllText(filePath, "content"); - - var result = _checker.FilterIgnored(new[] { filePath }); - - Assert.HasCount(1, result); - CollectionAssert.Contains(result.ToList(), filePath); - } - - [TestMethod] - public void FilterIgnored_TrackedFileMatchingGitignore_IsIncluded() - { - var trackedPath = Path.Combine(_testRepoPath, "tracked.log"); - File.WriteAllText(trackedPath, "content"); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "tracked.log"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Track log file", signature, signature); - } - - File.WriteAllText(Path.Combine(_testRepoPath, ".gitignore"), "*.log\n"); - - var result = _checker.FilterIgnored(new[] { trackedPath }); - - Assert.HasCount(1, result); - CollectionAssert.Contains(result.ToList(), trackedPath); - } - - [TestMethod] - public void FilterIgnored_SingleIgnoredFile_FiltersOut() - { - File.WriteAllText(Path.Combine(_testRepoPath, ".gitignore"), "*.log\n"); - var ignoredPath = Path.Combine(_testRepoPath, "test.log"); - File.WriteAllText(ignoredPath, "content"); - - var result = _checker.FilterIgnored(new[] { ignoredPath }); - - Assert.IsEmpty(result); - } - - [TestMethod] - public void FilterIgnored_MixedFiles_FiltersCorrectly() - { - File.WriteAllText(Path.Combine(_testRepoPath, ".gitignore"), "*.log\n"); - var trackedPath = Path.Combine(_testRepoPath, "test.cs"); - var ignoredPath = Path.Combine(_testRepoPath, "test.log"); - File.WriteAllText(trackedPath, "content"); - File.WriteAllText(ignoredPath, "content"); - - var result = _checker.FilterIgnored(new[] { trackedPath, ignoredPath }); - - Assert.HasCount(1, result); - CollectionAssert.Contains(result.ToList(), trackedPath); - CollectionAssert.DoesNotContain(result.ToList(), ignoredPath); - } - - [TestMethod] - public void FilterIgnored_MultipleFilesFromSameRepo_UseSingleRepository() - { - var paths = new List(); - for (int i = 0; i < 10; i++) - { - var filePath = Path.Combine(_testRepoPath, $"file{i}.cs"); - File.WriteAllText(filePath, "content"); - paths.Add(filePath); - } - - var result = _checker.FilterIgnored(paths); - - Assert.HasCount(10, result); - Assert.AreEqual(1, _checker.RepositoryOpenCount, "Should reuse a single Repository instance"); - } - - [TestMethod] - public void FilterIgnored_FileInGitDirectory_FiltersOut() - { - var gitConfigPath = Path.Combine(_testRepoPath, ".git", "config"); - - var result = _checker.FilterIgnored(new[] { gitConfigPath }); - - Assert.IsEmpty(result); - } - - [TestMethod] - public void FilterIgnored_NonRepoFile_PassesThrough() - { - var tempPath = Path.Combine(Path.GetTempPath(), $"non-repo-{Guid.NewGuid()}.cs"); - try - { - File.WriteAllText(tempPath, "content"); - - var result = _checker.FilterIgnored(new[] { tempPath }); - - Assert.HasCount(1, result); - CollectionAssert.Contains(result.ToList(), tempPath); - } - finally - { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - } - } - - [TestMethod] - public void FilterIgnored_CaseInsensitiveResult_ContainsOriginalPath() - { - var filePath = Path.Combine(_testRepoPath, "Test.cs"); - File.WriteAllText(filePath, "content"); - - var result = _checker.FilterIgnored(new[] { filePath }); - - Assert.HasCount(1, result, "Should contain exactly one file"); - Assert.AreEqual(1, result.Count(p => p.Equals(filePath, StringComparison.OrdinalIgnoreCase)), "Should contain the path (case-insensitive match)"); - Assert.AreEqual(1, result.Count(p => p.Equals(filePath.ToLower(), StringComparison.OrdinalIgnoreCase)), "Lowercase lookup should match"); - Assert.AreEqual(1, result.Count(p => p.Equals(filePath.ToUpper(), StringComparison.OrdinalIgnoreCase)), "Uppercase lookup should match"); - } - - private static void ForceDeleteDirectory(string path) - { - foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - Directory.Delete(path, true); - } - - private static string? TryDiscoverRepositoryPath(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - return null; - } - - try - { - return Repository.Discover(filePath); - } - catch (LibGit2SharpException) - { - return null; - } - } - - private static bool IsInGitDirectory(string filePath) - { - if (string.IsNullOrEmpty(filePath)) - { - return false; - } - - var separator = Path.DirectorySeparatorChar; - var gitDirPattern = separator + ".git" + separator; - - return filePath.IndexOf(gitDirPattern, StringComparison.OrdinalIgnoreCase) >= 0; - } - - private static string GetRelativePath(string basePath, string fullPath) - { - if (string.IsNullOrEmpty(basePath) || string.IsNullOrEmpty(fullPath)) - { - return fullPath; - } - - try - { - var baseUri = new Uri(AppendDirectorySeparatorChar(basePath)); - var fullUri = new Uri(fullPath); - var relativeUri = baseUri.MakeRelativeUri(fullUri); - return Uri.UnescapeDataString(relativeUri.ToString()).Replace('/', Path.DirectorySeparatorChar); - } - catch - { - return fullPath; - } - } - - private static string AppendDirectorySeparatorChar(string path) - { - if (!path.EndsWith(Path.DirectorySeparatorChar.ToString())) - { - return path + Path.DirectorySeparatorChar; - } - - return path; - } - - private class TestBatchGitIgnoreChecker : IBatchGitIgnoreChecker - { - private readonly ILogger _logger; - - public TestBatchGitIgnoreChecker(ILogger logger) - { - _logger = logger; - } - - public int RepositoryOpenCount { get; private set; } - - public HashSet FilterIgnored(IEnumerable absolutePaths) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - var pathList = absolutePaths.ToList(); - - if (pathList.Count == 0) - { - return result; - } - - var pathsByRepo = GroupByRepository(pathList); - - foreach (var group in pathsByRepo) - { - FilterGroupByIgnoreStatus(group.Key, group.Value, result); - } - - return result; - } - - private static void FilterPathsInRepo(Repository repo, string repoRoot, List paths, HashSet result) - { - foreach (var absolutePath in paths) - { - if (IsInGitDirectory(absolutePath)) - { - continue; - } - - var relativePath = GetRelativePath(repoRoot, absolutePath) - .Replace("\\", "/").Trim(); - - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = "."; - } - - if (!GitIgnoreSemantics.IsPathIgnoredConsideringIndex(repo, relativePath)) - { - result.Add(absolutePath); - } - } - } - - private void FilterGroupByIgnoreStatus(string repoPath, List paths, HashSet result) - { - if (string.IsNullOrEmpty(repoPath)) - { - result.UnionWith(paths); - return; - } - - try - { - RepositoryOpenCount++; - using (var repo = new Repository(repoPath)) - { - var repoRoot = repo.Info.WorkingDirectory; - if (string.IsNullOrEmpty(repoRoot)) - { - result.UnionWith(paths); - return; - } - - FilterPathsInRepo(repo, repoRoot, paths, result); - } - } - catch (LibGit2SharpException ex) - { - _logger.Warn($"BatchGitIgnoreChecker: LibGit2Sharp error for repo {repoPath}: {ex.Message}"); - result.UnionWith(paths); - } - } - - private Dictionary> GroupByRepository(List paths) - { - var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (var path in paths) - { - var repoPath = TryDiscoverRepositoryPath(path); - var key = repoPath ?? string.Empty; - - if (!result.ContainsKey(key)) - { - result[key] = new List(); - } - - result[key].Add(path); - } - - return result; - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CachingCodeReviewerTestBase.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CachingCodeReviewerTestBase.cs deleted file mode 100644 index 0a6e2cda..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CachingCodeReviewerTestBase.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - public abstract class CachingCodeReviewerTestBase - { - protected Mock _mockInnerReviewer = null!; - protected Mock _mockLogger = null!; - protected ReviewCacheService _cacheService = null!; - protected CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void BaseSetup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - - Setup(); - } - - [TestCleanup] - public void BaseCleanup() - { - Cleanup(); - } - - protected virtual void Setup() - { - } - - protected virtual void Cleanup() - { - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CancellationTokenTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CancellationTokenTests.cs deleted file mode 100644 index c4f43359..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CancellationTokenTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class CancellationTokenTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, _mockGitService.Object, null); - } - - [TestMethod] - public async Task DeltaAsync_CanceledCallerDoesNotCancelSharedDeltaComputation() - { - var path = "test.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "current-raw" }; - var currentCode = "current code"; - var precomputedScore = "baseline123"; - var expectedDelta = new DeltaResponseModel(); - var entered = new TaskCompletionSource(); - var gate = new TaskCompletionSource(); - var cts = new CancellationTokenSource(); - var callerToken = cts.Token; - var capturedToken = CancellationToken.None; - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns("old code"); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, precomputedScore, null, It.IsAny(), It.IsAny())) - .Returns(async (_, _, _, _, token, _) => - { - capturedToken = token; - entered.TrySetResult(true); - await gate.Task; - return expectedDelta; - }); - - var canceledCallerTask = _cachingReviewer.DeltaAsync(review, currentCode, precomputedScore, null, callerToken); - await entered.Task; - var secondCallerTask = _cachingReviewer.DeltaAsync(review, currentCode, precomputedScore); - cts.Cancel(); - gate.TrySetResult(true); - - await Assert.ThrowsAsync(() => canceledCallerTask); - - var secondResult = await secondCallerTask; - - Assert.AreEqual(expectedDelta, secondResult); - Assert.AreEqual(CancellationToken.None, capturedToken); - _mockInnerReviewer.Verify(r => r.DeltaAsync(review, currentCode, precomputedScore, null, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_ContentUnchanged_SkipsDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_ContentUnchanged_SkipsDeltaTests.cs deleted file mode 100644 index 6ec1439d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_ContentUnchanged_SkipsDeltaTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_ContentUnchanged_SkipsDeltaTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private Mock _mockNotifier = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private DeltaCacheService _deltaCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _mockNotifier = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _deltaCacheService = new DeltaCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - _deltaCacheService, - _mockLogger.Object, - _mockGitService.Object, - null, - _mockNotifier.Object); - } - - [TestMethod] - public async Task Test_ContentUnchanged_ReturnsNull() - { - var filePath = "C:\\test\\file.cs"; - var content = "public class Test { }"; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 8.0f, - RawScore = "rawscore123", - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(content); - - var result = await _cachingReviewer.DeltaAsync(review, content); - - Assert.IsNull(result); - } - - [TestMethod] - public async Task Test_ContentUnchanged_LogsDebugMessage() - { - var filePath = "C:\\test\\MyFile.cs"; - var content = "public class MyClass { }"; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 7.5f, - RawScore = "raw456", - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(content); - - await _cachingReviewer.DeltaAsync(review, content); - - _mockLogger.Verify(l => l.Debug(It.Is(msg => msg.Contains("Delta analysis skipped") && msg.Contains("MyFile.cs") && msg.Contains("content unchanged"))), Times.Once); - } - - [TestMethod] - public async Task Test_ContentUnchanged_DoesNotCallInnerReviewer() - { - var filePath = "C:\\test\\code.cs"; - var content = "using System; public class Code { }"; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 9.0f, - RawScore = "score789", - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(content); - - await _cachingReviewer.DeltaAsync(review, content); - - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Test_ContentUnchanged_OK() - { - var filePath = "C:\\test\\sample.cs"; - var content = "public interface ISample { }"; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 6.5f, - RawScore = "raw999", - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(content); - - await _cachingReviewer.DeltaAsync(review, content); - - var cachedDelta = _deltaCacheService.GetDeltaForFile(filePath); - Assert.IsNull(cachedDelta); - } - - [TestMethod] - public async Task Test_UndoChanges_ClearsCacheAndNotifies() - { - var filePath = Path.Combine(Path.GetTempPath(), $"Test_UndoChanges_ClearsCache{Guid.NewGuid()}.cs"); - var content = "public interface ISample { /* Changes */ }"; - File.WriteAllText(filePath, content); - try - { - var baselineContent = "public interface ISample { }"; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 6.5f, - RawScore = "raw999", - }; - _deltaCacheService.Put(new DeltaCacheEntry(filePath, content, baselineContent, new DeltaResponseModel())); - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(content); - - await _cachingReviewer.DeltaAsync(review, content); - - var cachedDelta = _deltaCacheService.GetDeltaForFile(filePath); - Assert.IsNull(cachedDelta); - _mockNotifier.Verify(x => x.RequestViewUpdate(), Times.Once); - } - finally - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - } - - [TestMethod] - public async Task Test_EmptyContentUnchanged_SkipsDelta() - { - var filePath = "C:\\test\\empty.cs"; - var content = string.Empty; - var review = new FileReviewModel - { - FilePath = filePath, - Score = 5.0f, - RawScore = "empty123", - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(string.Empty); - - var result = await _cachingReviewer.DeltaAsync(review, content); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Debug(It.Is(msg => msg.Contains("Delta analysis skipped") && msg.Contains("content unchanged"))), Times.Once); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DelegatesToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DelegatesToInnerReviewerTests.cs deleted file mode 100644 index 174508fa..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DelegatesToInnerReviewerTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_DelegatesToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, _mockGitService.Object, null); - } - - [TestMethod] - public async Task Test() - { - var path = "DeltaAsync_DelegatesToInnerReviewer.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - var precomputedScore = "baseline123"; - var expectedDelta = new DeltaResponseModel(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, precomputedScore, It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - var result = await _cachingReviewer.DeltaAsync(review, currentCode, precomputedScore); - - Assert.AreEqual(expectedDelta, result); - _mockInnerReviewer.Verify(r => r.DeltaAsync(review, currentCode, precomputedScore, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DeltaCacheHit_ReturnsCachedDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DeltaCacheHit_ReturnsCachedDeltaTests.cs deleted file mode 100644 index 8215a22c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DeltaCacheHit_ReturnsCachedDeltaTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_DeltaCacheHit_ReturnsCachedDeltaTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private DeltaCacheService _deltaCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - private string _tempFilePath = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _deltaCacheService = new DeltaCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - null, - null, - _deltaCacheService, - _mockLogger.Object, - _mockGitService.Object, - null); - _tempFilePath = Path.Combine(Path.GetTempPath(), $"DeltaAsync_DeltaCacheHit_{Guid.NewGuid()}.cs"); - File.WriteAllText(_tempFilePath, "temp content"); - } - - [TestCleanup] - public void Cleanup() - { - if (File.Exists(_tempFilePath)) - { - File.Delete(_tempFilePath); - } - } - - [TestMethod] - public async Task Test() - { - var currentCode = "public class DeltaCacheHit { void Method() { } }"; - var baselineCode = "public class DeltaCacheHit { }"; - var cachedDelta = new DeltaResponseModel - { - NewScore = 8.5m, - OldScore = 9.0m, - ScoreChange = -0.5m, - }; - var review = new FileReviewModel - { - FilePath = _tempFilePath, - Score = 8.5f, - RawScore = "current789", - }; - - _deltaCacheService.Put(new DeltaCacheEntry(_tempFilePath, baselineCode, currentCode, cachedDelta)); - _mockGitService.Setup(g => g.GetFileContentForCommit(_tempFilePath, It.IsAny())).Returns(baselineCode); - - var result = await _cachingReviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - Assert.AreEqual(cachedDelta.NewScore, result.NewScore); - Assert.AreEqual(cachedDelta.OldScore, result.OldScore); - Assert.AreEqual(cachedDelta.ScoreChange, result.ScoreChange); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_IdenticalScores_SkipsDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_IdenticalScores_SkipsDeltaTests.cs deleted file mode 100644 index afd52e13..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_IdenticalScores_SkipsDeltaTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_IdenticalScores_SkipsDeltaTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private DeltaCacheService _deltaCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _deltaCacheService = new DeltaCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - _deltaCacheService, - _mockLogger.Object, - _mockGitService.Object, - null); - } - - [TestMethod] - public async Task Test_IdenticalScores_ReturnsNull() - { - var filePath = "C:\\test\\file.cs"; - var currentContent = "public class Current { }"; - var oldContent = "public class Old { }"; - var identicalRawScore = "rawscore123"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 8.0f, - RawScore = identicalRawScore, - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, identicalRawScore); - - var result = await _cachingReviewer.DeltaAsync(review, currentContent); - - Assert.IsNull(result); - } - - [TestMethod] - public async Task Test_IdenticalScores_LogsDebugMessage() - { - var filePath = "C:\\test\\MyFile.cs"; - var currentContent = "public class NewVersion { }"; - var oldContent = "public class OldVersion { }"; - var identicalRawScore = "raw456"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 7.5f, - RawScore = identicalRawScore, - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, identicalRawScore); - - await _cachingReviewer.DeltaAsync(review, currentContent); - - _mockLogger.Verify(l => l.Debug(It.Is(msg => msg.Contains("Delta analysis skipped") && msg.Contains("MyFile.cs") && msg.Contains("scores identical"))), Times.Once); - } - - [TestMethod] - public async Task Test_IdenticalScores_DoesNotCallInnerReviewer() - { - var filePath = "C:\\test\\code.cs"; - var currentContent = "public class CodeV2 { }"; - var oldContent = "public class CodeV1 { }"; - var identicalRawScore = "score789"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 9.0f, - RawScore = identicalRawScore, - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, identicalRawScore); - - await _cachingReviewer.DeltaAsync(review, currentContent); - - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Test_IdenticalScores_CachesNullResult() - { - var filePath = "C:\\test\\sample.cs"; - var currentContent = "public interface ISampleNew { }"; - var oldContent = "public interface ISampleOld { }"; - var identicalRawScore = "raw999"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 6.5f, - RawScore = identicalRawScore, - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, identicalRawScore); - - await _cachingReviewer.DeltaAsync(review, currentContent); - - var cachedDelta = _deltaCacheService.GetDeltaForFile(filePath); - Assert.IsNull(cachedDelta); - } - - [TestMethod] - public async Task Test_IdenticalScoresWithPrecomputed_SkipsDelta() - { - var filePath = "C:\\test\\precomputed.cs"; - var currentContent = "public class Precomputed { }"; - var oldContent = "public class PrecomputedOld { }"; - var identicalRawScore = "precomputed123"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 5.0f, - RawScore = identicalRawScore, - }; - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - - var result = await _cachingReviewer.DeltaAsync(review, currentContent, identicalRawScore); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Debug(It.Is(msg => msg.Contains("Delta analysis skipped") && msg.Contains("scores identical"))), Times.Once); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_InFlightCoalescingTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_InFlightCoalescingTests.cs deleted file mode 100644 index 8fbddd17..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_InFlightCoalescingTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_InFlightCoalescingTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, _mockGitService.Object, null); - } - - [TestMethod] - public async Task DeltaAsync_ConcurrentIdenticalRequests_CallsInnerReviewerOnce() - { - var path = "coalesce.cs"; - var review = new FileReviewModel { FilePath = path, RawScore = "new-raw", Score = 7.5f }; - var currentCode = "new code"; - var oldCode = "old code"; - var precomputedBaselineRawScore = "old-raw"; - var expectedDelta = new DeltaResponseModel(); - var entered = new TaskCompletionSource(); - var gate = new TaskCompletionSource(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, precomputedBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async () => - { - entered.TrySetResult(true); - await gate.Task; - return expectedDelta; - }); - - var first = _cachingReviewer.DeltaAsync(review, currentCode, precomputedBaselineRawScore); - var second = _cachingReviewer.DeltaAsync(review, currentCode, precomputedBaselineRawScore); - - await entered.Task; - _mockInnerReviewer.Verify( - r => r.DeltaAsync(review, currentCode, precomputedBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - - gate.TrySetResult(true); - var firstResult = await first; - var secondResult = await second; - - Assert.AreEqual(expectedDelta, firstResult); - Assert.AreEqual(expectedDelta, secondResult); - _mockInnerReviewer.Verify( - r => r.DeltaAsync(review, currentCode, precomputedBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_ConcurrentRequestsWithDifferentBaselineScores_CallInnerReviewerTwice() - { - var path = "coalesce.cs"; - var review = new FileReviewModel { FilePath = path, RawScore = "new-raw", Score = 7.5f }; - var currentCode = "new code"; - var oldCode = "old code"; - var firstBaselineRawScore = "old-raw-1"; - var secondBaselineRawScore = "old-raw-2"; - var firstEntered = new TaskCompletionSource(); - var secondEntered = new TaskCompletionSource(); - var gate = new TaskCompletionSource(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, firstBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async () => - { - firstEntered.TrySetResult(true); - await gate.Task; - return new DeltaResponseModel(); - }); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, secondBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async () => - { - secondEntered.TrySetResult(true); - await gate.Task; - return new DeltaResponseModel(); - }); - - var first = _cachingReviewer.DeltaAsync(review, currentCode, firstBaselineRawScore); - var second = _cachingReviewer.DeltaAsync(review, currentCode, secondBaselineRawScore); - - await Task.WhenAll(firstEntered.Task, secondEntered.Task); - - _mockInnerReviewer.Verify( - r => r.DeltaAsync(review, currentCode, firstBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - _mockInnerReviewer.Verify( - r => r.DeltaAsync(review, currentCode, secondBaselineRawScore, It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - - gate.TrySetResult(true); - await Task.WhenAll(first, second); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_NullFilePath_ReturnsNullTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_NullFilePath_ReturnsNullTests.cs deleted file mode 100644 index f6aaabf4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_NullFilePath_ReturnsNullTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_NullFilePath_ReturnsNullTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - null, - _mockLogger.Object, - _mockGitService.Object, - null); - } - - [TestMethod] - public async Task Test_NullFilePath() - { - var review = new FileReviewModel - { - FilePath = null, - Score = 8.0f, - RawScore = "rawscore123", - }; - var currentCode = "public class Test { }"; - - var result = await _cachingReviewer.DeltaAsync(review, currentCode); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Warn("Could not review file, missing file path."), Times.Once); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Test_EmptyFilePath() - { - var review = new FileReviewModel - { - FilePath = string.Empty, - Score = 8.0f, - RawScore = "rawscore123", - }; - var currentCode = "public class Test { }"; - - var result = await _cachingReviewer.DeltaAsync(review, currentCode); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Warn("Could not review file, missing file path."), Times.Once); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Test_WhitespaceFilePath() - { - var review = new FileReviewModel - { - FilePath = " ", - Score = 8.0f, - RawScore = "rawscore123", - }; - var currentCode = "public class Test { }"; - - var result = await _cachingReviewer.DeltaAsync(review, currentCode); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Warn("Could not review file, missing file path."), Times.Once); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithNotifier_CallsNotifierTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithNotifier_CallsNotifierTests.cs deleted file mode 100644 index 3d8c9ef1..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithNotifier_CallsNotifierTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_WithNotifier_CallsNotifierTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private Mock _mockNotifier = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _mockNotifier = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _cacheService, - null, - null, - _mockLogger.Object, - _mockGitService.Object, - null, - _mockNotifier.Object); - } - - [TestMethod] - public async Task CallsOnDeltaStartingAndOnDeltaCompleted() - { - var path = "TestFile.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - var expectedDelta = new DeltaResponseModel(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - await _cachingReviewer.DeltaAsync(review, currentCode); - - _mockNotifier.Verify(n => n.OnDeltaStarting(path), Times.Once); - _mockNotifier.Verify(n => n.OnDeltaCompleted(path), Times.Once); - } - - [TestMethod] - public async Task CallsOnDeltaCompletedEvenWhenInnerReviewerThrows() - { - var path = "TestFile.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("Test exception")); - - await _cachingReviewer.DeltaAsync(review, currentCode); - - _mockNotifier.Verify(n => n.OnDeltaStarting(path), Times.Once); - _mockNotifier.Verify(n => n.OnDeltaCompleted(path), Times.Once); - } - - [TestMethod] - public async Task RethrowsOperationCanceledExceptionAndStillCompletesNotifierLifecycle() - { - var path = "TestFile.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); - - await Assert.ThrowsAsync(() => _cachingReviewer.DeltaAsync(review, currentCode)); - - _mockNotifier.Verify(n => n.OnDeltaStarting(path), Times.Once); - _mockNotifier.Verify(n => n.OnDeltaCompleted(path), Times.Once); - _mockLogger.Verify(l => l.Error(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task DoesNotCallNotifierWhenFilePathIsNull() - { - var review = new FileReviewModel { FilePath = null, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - - await _cachingReviewer.DeltaAsync(review, currentCode); - - _mockNotifier.Verify(n => n.OnDeltaStarting(It.IsAny()), Times.Never); - _mockNotifier.Verify(n => n.OnDeltaCompleted(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task DoesNotCallNotifierWhenFilePathIsWhitespace() - { - var review = new FileReviewModel { FilePath = " ", Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - - await _cachingReviewer.DeltaAsync(review, currentCode); - - _mockNotifier.Verify(n => n.OnDeltaStarting(It.IsAny()), Times.Never); - _mockNotifier.Verify(n => n.OnDeltaCompleted(It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithTelemetryManager_SendsTelemetryTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithTelemetryManager_SendsTelemetryTests.cs deleted file mode 100644 index e9867873..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithTelemetryManager_SendsTelemetryTests.cs +++ /dev/null @@ -1,331 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class DeltaAsync_WithTelemetryManager_SendsTelemetryTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private Mock _mockTelemetryManager = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private DeltaCacheService _deltaCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - private string _tempDir = null!; - private List _tempFiles = new List(); - - [TestInitialize] - public void Setup() - { - _tempDir = Path.Combine(Path.GetTempPath(), "CodeSceneTests_" + Path.GetRandomFileName()); - Directory.CreateDirectory(_tempDir); - - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _mockTelemetryManager = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _deltaCacheService = new DeltaCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - _deltaCacheService, - _mockLogger.Object, - _mockGitService.Object, - _mockTelemetryManager.Object); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_tempDir)) - { - try - { - Directory.Delete(_tempDir, true); - } - catch - { - } - } - } - - [TestMethod] - public async Task Test_DeltaComputed_WithTelemetryManager() - { - var filePath = CreateTempFile("file.cs"); - var currentContent = "public class Current { }"; - var oldContent = "public class Old { }"; - var currentRawScore = "current123"; - var oldRawScore = "old456"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 8.0f, - RawScore = currentRawScore, - }; - - var expectedDelta = CreateDeltaResponse(0.5m); - var telemetrySent = new TaskCompletionSource(); - - _mockTelemetryManager - .Setup(t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .Callback(() => telemetrySent.TrySetResult(true)) - .Returns(Task.CompletedTask); - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, oldRawScore); - _mockInnerReviewer.Setup(r => r.DeltaAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(expectedDelta); - - var result = await _cachingReviewer.DeltaAsync(review, currentContent); - - await Task.WhenAny(telemetrySent.Task, Task.Delay(5000)); - Assert.IsTrue(telemetrySent.Task.IsCompleted, "Telemetry should have been sent"); - _mockTelemetryManager.Verify( - t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny()), - Times.AtLeastOnce()); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedDelta, result); - } - - [TestMethod] - public async Task Test_DeltaComputedWithDifferentScores_WithTelemetryManager() - { - var filePath = CreateTempFile("MyFile.cs"); - var currentContent = "public class NewVersion { }"; - var oldContent = "public class OldVersion { }"; - var currentRawScore = "raw789"; - var oldRawScore = "raw123"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 7.5f, - RawScore = currentRawScore, - }; - - var expectedDelta = CreateDeltaResponse(-1.0m); - var telemetrySent = new TaskCompletionSource(); - - _mockTelemetryManager - .Setup(t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .Callback(() => telemetrySent.TrySetResult(true)) - .Returns(Task.CompletedTask); - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, oldRawScore); - _mockInnerReviewer.Setup(r => r.DeltaAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(expectedDelta); - - var result = await _cachingReviewer.DeltaAsync(review, currentContent); - - await Task.WhenAny(telemetrySent.Task, Task.Delay(5000)); - Assert.IsTrue(telemetrySent.Task.IsCompleted, "Telemetry should have been sent"); - _mockTelemetryManager.Verify( - t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny()), - Times.AtLeastOnce()); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedDelta, result); - } - - [TestMethod] - public async Task Test_NullTelemetryManager_DoesNotThrow() - { - var filePath = CreateTempFile("code.cs"); - var currentContent = "public class CodeV2 { }"; - var oldContent = "public class CodeV1 { }"; - var currentRawScore = "score999"; - var oldRawScore = "score111"; - - var cachingReviewerWithoutTelemetry = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - _deltaCacheService, - _mockLogger.Object, - _mockGitService.Object, - null); - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 9.0f, - RawScore = currentRawScore, - }; - - var expectedDelta = CreateDeltaResponse(0.0m); - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _baselineCacheService.Put(filePath, oldContent, oldRawScore); - _mockInnerReviewer.Setup(r => r.DeltaAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(expectedDelta); - - var result = await cachingReviewerWithoutTelemetry.DeltaAsync(review, currentContent); - - Assert.IsNotNull(result); - } - - [TestMethod] - public async Task Test_DeltaCacheMiss_WithTelemetryManager() - { - var filePath = CreateTempFile("sample.cs"); - var currentContent = "public interface ISampleNew { }"; - var oldContent = "public interface ISampleOld { }"; - var currentRawScore = "raw555"; - var oldRawScore = "raw666"; - - var review = new FileReviewModel - { - FilePath = filePath, - Score = 6.5f, - RawScore = currentRawScore, - }; - - var expectedDelta = CreateDeltaResponse(-0.5m); - var telemetrySent = new TaskCompletionSource(); - - _mockTelemetryManager - .Setup(t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .Callback(() => telemetrySent.TrySetResult(true)) - .Returns(Task.CompletedTask); - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath, It.IsAny())).Returns(oldContent); - _mockInnerReviewer.Setup(r => r.ReviewAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new FileReviewModel { RawScore = oldRawScore }); - _mockInnerReviewer.Setup(r => r.DeltaAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(expectedDelta); - - var result = await _cachingReviewer.DeltaAsync(review, currentContent); - - await Task.WhenAny(telemetrySent.Task, Task.Delay(5000)); - Assert.IsTrue(telemetrySent.Task.IsCompleted, "Telemetry should have been sent"); - _mockTelemetryManager.Verify( - t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny()), - Times.AtLeastOnce()); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedDelta, result); - } - - [TestMethod] - public async Task Test_MultipleDeltaComputations_WithTelemetryManager() - { - var filePath1 = CreateTempFile("file1.cs"); - var filePath2 = CreateTempFile("file2.cs"); - var currentContent1 = "public class File1 { }"; - var currentContent2 = "public class File2 { }"; - var oldContent1 = "public class File1Old { }"; - var oldContent2 = "public class File2Old { }"; - - var review1 = new FileReviewModel - { - FilePath = filePath1, - Score = 8.0f, - RawScore = "score1", - }; - - var review2 = new FileReviewModel - { - FilePath = filePath2, - Score = 7.0f, - RawScore = "score2", - }; - - var telemetrySent = new TaskCompletionSource(); - - _mockTelemetryManager - .Setup(t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .Callback(() => telemetrySent.TrySetResult(true)) - .Returns(Task.CompletedTask); - - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath1, It.IsAny())).Returns(oldContent1); - _mockGitService.Setup(g => g.GetFileContentForCommit(filePath2, It.IsAny())).Returns(oldContent2); - _baselineCacheService.Put(filePath1, oldContent1, "oldScore1"); - _baselineCacheService.Put(filePath2, oldContent2, "oldScore2"); - _mockInnerReviewer.Setup(r => r.DeltaAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(CreateDeltaResponse(0.5m)); - - var result1 = await _cachingReviewer.DeltaAsync(review1, currentContent1); - var result2 = await _cachingReviewer.DeltaAsync(review2, currentContent2); - - await Task.WhenAny(telemetrySent.Task, Task.Delay(5000)); - Assert.IsTrue(telemetrySent.Task.IsCompleted, "Telemetry should have been sent"); - _mockTelemetryManager.Verify( - t => t.SendTelemetryAsync(It.IsAny(), It.IsAny>(), It.IsAny()), - Times.AtLeastOnce()); - - Assert.IsNotNull(result1); - Assert.IsNotNull(result2); - } - - private static DeltaResponseModel CreateDeltaResponse(decimal scoreChange) - { - return new DeltaResponseModel - { - ScoreChange = scoreChange, - OldScore = 8.0m, - NewScore = 8.0m + scoreChange, - FileLevelFindings = new ChangeDetailModel[0], - FunctionLevelFindings = new FunctionFindingModel[0], - }; - } - - private string CreateTempFile(string fileName) - { - var filePath = Path.Combine(_tempDir, fileName); - File.WriteAllText(filePath, string.Empty); - _tempFiles.Add(filePath); - return filePath; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_BaselineCacheHit_ReturnsCachedScoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_BaselineCacheHit_ReturnsCachedScoreTests.cs deleted file mode 100644 index 84f5e362..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_BaselineCacheHit_ReturnsCachedScoreTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class GetOrComputeBaselineRawScoreAsync_BaselineCacheHit_ReturnsCachedScoreTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - null, - _baselineCacheService, - null, - _mockLogger.Object, - null, - null); - } - - [TestMethod] - public async Task Test() - { - var path = "GetOrComputeBaselineRawScoreAsync_CacheHit.cs"; - var baselineCode = "public class BaselineCode { int x; }"; - var cachedRawScore = "baseline_raw_score_123"; - - _baselineCacheService.Put(path, baselineCode, cachedRawScore); - - var result = await _cachingReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineCode); - - Assert.AreEqual(cachedRawScore, result); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewerTests.cs deleted file mode 100644 index 7e812adb..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewerTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewer.cs"; - var baselineContent = "baseline content"; - var expectedScore = "baseline123"; - var baselineReview = new FileReviewModel { FilePath = path, RawScore = expectedScore }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, baselineContent, true, It.IsAny(), It.IsAny())) - .ReturnsAsync(baselineReview); - - var result = await _cachingReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - - Assert.AreEqual(expectedScore, result); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, baselineContent, true, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_CacheHit_ReturnsCachedReviewTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_CacheHit_ReturnsCachedReviewTests.cs deleted file mode 100644 index fc16f08a..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_CacheHit_ReturnsCachedReviewTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAndBaselineAsync_CacheHit_ReturnsCachedReviewTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - null, - _mockLogger.Object, - _mockGitService.Object, - null); - } - - [TestMethod] - public async Task Test() - { - var path = "ReviewAndBaselineAsync_CacheHit.cs"; - var currentCode = "public class CacheHit { }"; - var baselineCode = "public class CacheHit { int x; }"; - var cachedReview = new FileReviewModel - { - FilePath = path, - Score = 9.0f, - RawScore = "current123", - }; - var cachedBaselineRawScore = "baseline456"; - - _reviewCacheService.Put(new ReviewCacheEntry(currentCode, path.ToLowerInvariant(), cachedReview)); - _baselineCacheService.Put(path, baselineCode, cachedBaselineRawScore); - _mockGitService.Setup(g => g.GetFileContentForCommit(path, It.IsAny())).Returns(baselineCode); - - var result = await _cachingReviewer.ReviewAndBaselineAsync(path, currentCode); - - Assert.IsNotNull(result.review); - Assert.AreEqual(cachedReview.Score, result.review.Score); - Assert.AreEqual(cachedReview.RawScore, result.review.RawScore); - Assert.AreEqual(cachedBaselineRawScore, result.baselineRawScore); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_DelegatesToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_DelegatesToInnerReviewerTests.cs deleted file mode 100644 index c398abd4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_DelegatesToInnerReviewerTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAndBaselineAsync_DelegatesToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "ReviewAndBaselineAsync_DelegatesToInnerReviewer.cs"; - var currentCode = "current code"; - var expectedReview = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, currentCode, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedReview); - - var result = await _cachingReviewer.ReviewAndBaselineAsync(path, currentCode); - - Assert.AreEqual(expectedReview, result.review); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, currentCode, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_AfterCacheInvalidation_CallsInnerReviewerAgainTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_AfterCacheInvalidation_CallsInnerReviewerAgainTests.cs deleted file mode 100644 index e2cb1566..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_AfterCacheInvalidation_CallsInnerReviewerAgainTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_AfterCacheInvalidation_CallsInnerReviewerAgainTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "AfterCacheInvalidation_CallsInnerReviewerAgain.cs"; - var content = "public class AfterCacheInvalidationCallsInnerReviewerAgain { }"; - var originalResult = new FileReviewModel { FilePath = path, Score = 8.0f }; - var newResult = new FileReviewModel { FilePath = path, Score = 9.0f }; - - _mockInnerReviewer - .SetupSequence(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(originalResult) - .ReturnsAsync(newResult); - - var firstResult = await _cachingReviewer.ReviewAsync(path, content); - Assert.AreEqual(8.0f, firstResult.Score); - - _cacheService.Invalidate(path.ToLowerInvariant()); - - var secondResult = await _cachingReviewer.ReviewAsync(path, content); - - Assert.AreEqual(9.0f, secondResult.Score, "Should get fresh result after invalidation"); - _mockInnerReviewer.Verify( - r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), - Times.Exactly(2), - "Inner reviewer should be called twice - once before and once after invalidation"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheHit_ReturnsFromCacheWithoutCallingInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheHit_ReturnsFromCacheWithoutCallingInnerReviewerTests.cs deleted file mode 100644 index bf8f7478..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheHit_ReturnsFromCacheWithoutCallingInnerReviewerTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_CacheHit_ReturnsFromCacheWithoutCallingInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "CacheHit_ReturnsFromCache.cs"; - var content = "public class CacheHitReturnsFromCache { }"; - var cachedResult = new FileReviewModel - { - FilePath = path, - Score = 9.0f, - RawScore = "cached123", - }; - - _cacheService.Put(new Models.Cache.Review.ReviewCacheEntry(content, path.ToLowerInvariant(), cachedResult)); - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNotNull(result); - Assert.AreEqual(cachedResult.Score, result.Score); - Assert.AreEqual(cachedResult.RawScore, result.RawScore); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_CachesResultTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_CachesResultTests.cs deleted file mode 100644 index 2e8d7359..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_CachesResultTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_CacheMiss_CachesResultTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "CacheMiss_CachesResult.cs"; - var content = "public class CacheMissCachesResult { }"; - var expectedResult = new FileReviewModel - { - FilePath = path, - Score = 8.5f, - }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedResult); - - await _cachingReviewer.ReviewAsync(path, content); - - var cachedResult = _cacheService.Get(new Models.Cache.Review.ReviewCacheQuery(content, path.ToLowerInvariant())); - Assert.IsNotNull(cachedResult); - Assert.AreEqual(expectedResult.Score, cachedResult.Score); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_DelegatesToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_DelegatesToInnerReviewerTests.cs deleted file mode 100644 index 55946037..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_DelegatesToInnerReviewerTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_CacheMiss_DelegatesToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "CacheMiss_DelegatesToInnerReviewer.cs"; - var content = "public class CacheMissDelegatesToInnerReviewer { }"; - var expectedResult = new FileReviewModel - { - FilePath = path, - Score = 8.5f, - RawScore = "raw123", - FileLevel = new List(), - FunctionLevel = new List(), - }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedResult); - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedResult.Score, result.Score); - Assert.AreEqual(expectedResult.FilePath, result.FilePath); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_ConcurrentRequestsDifferentContent_SeparateCliCallsTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_ConcurrentRequestsDifferentContent_SeparateCliCallsTests.cs deleted file mode 100644 index abd6360e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_ConcurrentRequestsDifferentContent_SeparateCliCallsTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_ConcurrentRequestsDifferentContent_SeparateCliCallsTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "test.cs"; - var content1 = "public class Test { }"; - var content2 = "public class Test { int x; }"; - var result1 = new FileReviewModel { FilePath = path, Score = 8.0f }; - var result2 = new FileReviewModel { FilePath = path, Score = 7.5f }; - - var tcs1 = new TaskCompletionSource(); - var tcs2 = new TaskCompletionSource(); - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content1, false, It.IsAny(), It.IsAny())) - .Returns(tcs1.Task); - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content2, false, It.IsAny(), It.IsAny())) - .Returns(tcs2.Task); - - var task1 = _cachingReviewer.ReviewAsync(path, content1); - var task2 = _cachingReviewer.ReviewAsync(path, content2); - - tcs1.SetResult(result1); - tcs2.SetResult(result2); - - var results = await Task.WhenAll(task1, task2); - - Assert.AreEqual(8.0f, results[0].Score); - Assert.AreEqual(7.5f, results[1].Score); - _mockInnerReviewer.Verify( - r => r.ReviewAsync(path, content1, false, It.IsAny(), It.IsAny()), - Times.Once); - _mockInnerReviewer.Verify( - r => r.ReviewAsync(path, content2, false, It.IsAny(), It.IsAny()), - Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentContent_CausesNewCliCallTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentContent_CausesNewCliCallTests.cs deleted file mode 100644 index 0856b735..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentContent_CausesNewCliCallTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_DifferentContent_CausesNewCliCallTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "DifferentContent_CausesNewCliCall.cs"; - var originalContent = "public class DifferentContentOriginal { }"; - var modifiedContent = "public class DifferentContentModified { int x; }"; - - var originalResult = new FileReviewModel { FilePath = path, Score = 8.0f }; - var modifiedResult = new FileReviewModel { FilePath = path, Score = 7.5f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, originalContent, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(originalResult); - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, modifiedContent, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(modifiedResult); - - var firstResult = await _cachingReviewer.ReviewAsync(path, originalContent); - var secondResult = await _cachingReviewer.ReviewAsync(path, modifiedContent); - - Assert.AreEqual(8.0f, firstResult.Score); - Assert.AreEqual(7.5f, secondResult.Score); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, originalContent, false, It.IsAny(), It.IsAny()), Times.Once); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, modifiedContent, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentFiles_CachedIndependentlyTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentFiles_CachedIndependentlyTests.cs deleted file mode 100644 index 4b8aa550..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentFiles_CachedIndependentlyTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_DifferentFiles_CachedIndependentlyTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var content = "public class DifferentFilesCachedIndependently { }"; - var file1 = "DifferentFiles_CachedIndependently_1.cs"; - var file2 = "DifferentFiles_CachedIndependently_2.cs"; - var result1 = new FileReviewModel { FilePath = file1, Score = 8.0f }; - var result2 = new FileReviewModel { FilePath = file2, Score = 9.0f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(file1, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result1); - _mockInnerReviewer - .Setup(r => r.ReviewAsync(file2, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result2); - - var firstResult = await _cachingReviewer.ReviewAsync(file1, content); - var secondResult = await _cachingReviewer.ReviewAsync(file2, content); - - Assert.AreEqual(8.0f, firstResult.Score); - Assert.AreEqual(9.0f, secondResult.Score); - _mockInnerReviewer.Verify(r => r.ReviewAsync(file1, content, false, It.IsAny(), It.IsAny()), Times.Once); - _mockInnerReviewer.Verify(r => r.ReviewAsync(file2, content, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_EmptyContent_DelegatesToInnerReviewerWithoutCachingTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_EmptyContent_DelegatesToInnerReviewerWithoutCachingTests.cs deleted file mode 100644 index 0d9c24b1..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_EmptyContent_DelegatesToInnerReviewerWithoutCachingTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_EmptyContent_DelegatesToInnerReviewerWithoutCachingTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "EmptyContent_DelegatesToInnerReviewer.cs"; - var content = string.Empty; - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerReturnsNull_DoesNotCacheTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerReturnsNull_DoesNotCacheTests.cs deleted file mode 100644 index 7cb13e0f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerReturnsNull_DoesNotCacheTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_InnerReviewerReturnsNull_DoesNotCacheTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "InnerReviewerReturnsNull_DoesNotCache.cs"; - var content = "public class InnerReviewerReturnsNullDoesNotCache { }"; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync((FileReviewModel?)null); - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - var cachedResult = _cacheService.Get(new Models.Cache.Review.ReviewCacheQuery(content, path)); - Assert.IsNull(cachedResult); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerThrows_PropagatesExceptionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerThrows_PropagatesExceptionTests.cs deleted file mode 100644 index aaab61ac..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerThrows_PropagatesExceptionTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_InnerReviewerThrows_PropagatesExceptionTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "InnerReviewerThrows_PropagatesException.cs"; - var content = "public class InnerReviewerThrowsPropagatesException { }"; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("CLI process failed")); - - var exception = await Assert.ThrowsAsync(() => - _cachingReviewer.ReviewAsync(path, content)); - Assert.AreEqual("CLI process failed", exception.Message); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_MultipleConcurrentRequests_OnlyOneCliCallTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_MultipleConcurrentRequests_OnlyOneCliCallTests.cs deleted file mode 100644 index 2409a389..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_MultipleConcurrentRequests_OnlyOneCliCallTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_MultipleConcurrentRequests_OnlyOneCliCallTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "MultipleConcurrentRequests_OnlyOneCliCall.cs"; - var content = "public class MultipleConcurrentRequestsOnlyOneCliCall { }"; - var result = new FileReviewModel { FilePath = path, Score = 8.5f }; - - var tcs = new TaskCompletionSource(); - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .Returns(tcs.Task); - - var task1 = _cachingReviewer.ReviewAsync(path, content); - var task2 = _cachingReviewer.ReviewAsync(path, content); - var task3 = _cachingReviewer.ReviewAsync(path, content); - - tcs.SetResult(result); - - var results = await Task.WhenAll(task1, task2, task3); - - Assert.HasCount(3, results); - Assert.IsTrue( - results.All(r => r != null && r.Score == 8.5f), - "All results should be identical"); - - _mockInnerReviewer.Verify( - r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), - Times.Once, - "Concurrent requests with same content should coalesce into ONE CLI call"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullContent_DelegatesToInnerReviewerWithoutCachingTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullContent_DelegatesToInnerReviewerWithoutCachingTests.cs deleted file mode 100644 index 56e383c4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullContent_DelegatesToInnerReviewerWithoutCachingTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_NullContent_DelegatesToInnerReviewerWithoutCachingTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "NullContent_DelegatesToInnerReviewer.cs"; - string content = null!; - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullPath_DelegatesToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullPath_DelegatesToInnerReviewerTests.cs deleted file mode 100644 index 549445a8..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullPath_DelegatesToInnerReviewerTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_NullPath_DelegatesToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - string path = null!; - var content = "public class NullPathDelegatesToInnerReviewer { }"; - - var result = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - _mockInnerReviewer.Verify(r => r.ReviewAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_PathCaseSensitivity_CacheInconsistencyTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_PathCaseSensitivity_CacheInconsistencyTests.cs deleted file mode 100644 index df16a71c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_PathCaseSensitivity_CacheInconsistencyTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_PathCaseSensitivity_CacheInconsistencyTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test_DifferentCasePaths_SameContent_ShouldOnlyCallInnerReviewerOnce() - { - var pathUpperCase = "Foo.cs"; - var pathLowerCase = "foo.cs"; - var content = "public class Foo { }"; - var result = new FileReviewModel { FilePath = pathUpperCase, Score = 8.5f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(It.IsAny(), content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - - // First request with uppercase path - var result1 = await _cachingReviewer.ReviewAsync(pathUpperCase, content); - - // Second request with lowercase path - same content, should ideally hit cache - var result2 = await _cachingReviewer.ReviewAsync(pathLowerCase, content); - - Assert.IsNotNull(result1); - Assert.IsNotNull(result2); - Assert.AreEqual(8.5f, result1.Score); - Assert.AreEqual(8.5f, result2.Score); - - // This assertion will FAIL if case sensitivity causes cache miss - _mockInnerReviewer.Verify( - r => r.ReviewAsync(It.IsAny(), content, false, It.IsAny(), It.IsAny()), - Times.Once, - "Same content with different path casing should use cached result, not call inner reviewer twice"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_SameContent_UsesCacheOnSecondCallTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_SameContent_UsesCacheOnSecondCallTests.cs deleted file mode 100644 index 03696d53..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_SameContent_UsesCacheOnSecondCallTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_SameContent_UsesCacheOnSecondCallTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "SameContent_UsesCacheOnSecondCall.cs"; - var content = "public class SameContentUsesCacheOnSecondCall { }"; - var result = new FileReviewModel { FilePath = path, Score = 8.5f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - - var firstResult = await _cachingReviewer.ReviewAsync(path, content); - var secondResult = await _cachingReviewer.ReviewAsync(path, content); - - Assert.AreEqual(firstResult.Score, secondResult.Score); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithDefaultCache_CreatesNewCacheInstanceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithDefaultCache_CreatesNewCacheInstanceTests.cs deleted file mode 100644 index f79c0f0b..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithDefaultCache_CreatesNewCacheInstanceTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_WithDefaultCache_CreatesNewCacheInstanceTests - { - private Mock _mockInnerReviewer = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object); - } - - [TestMethod] - public async Task Test() - { - var path = "WithDefaultCache_CreatesNewInstance.cs"; - var content = "public class WithDefaultCacheCreatesNewInstance { }"; - var result = new FileReviewModel { FilePath = path, Score = 8.5f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - - var firstResult = await _cachingReviewer.ReviewAsync(path, content); - var secondResult = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNotNull(firstResult); - Assert.IsNotNull(secondResult); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithIsBaselineTrue_PassesThroughToInnerReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithIsBaselineTrue_PassesThroughToInnerReviewerTests.cs deleted file mode 100644 index 15a08dde..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithIsBaselineTrue_PassesThroughToInnerReviewerTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_WithIsBaselineTrue_PassesThroughToInnerReviewerTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, null, null); - } - - [TestMethod] - public async Task Test() - { - var path = "WithIsBaselineTrue_PassesThrough.cs"; - var content = "public class WithIsBaselineTruePassesThrough { }"; - var result = new FileReviewModel { FilePath = path, Score = 8.0f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, true, It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - - var reviewResult = await _cachingReviewer.ReviewAsync(path, content, isBaseline: true); - - Assert.IsNotNull(reviewResult); - _mockInnerReviewer.Verify(r => r.ReviewAsync(path, content, true, It.IsAny(), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithoutLogger_DoesNotThrowTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithoutLogger_DoesNotThrowTests.cs deleted file mode 100644 index 0cda9578..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithoutLogger_DoesNotThrowTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewAsync_WithoutLogger_DoesNotThrowTests - { - private Mock _mockInnerReviewer = null!; - private ReviewCacheService _cacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null); - } - - [TestMethod] - public async Task Test() - { - var path = "WithoutLogger_DoesNotThrow.cs"; - var content = "public class WithoutLoggerDoesNotThrow { }"; - var result = new FileReviewModel { FilePath = path, Score = 8.5f }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - - var reviewResult = await _cachingReviewer.ReviewAsync(path, content); - - Assert.IsNotNull(reviewResult); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_BaselineCommit_UsesProvidedCommitTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_BaselineCommit_UsesProvidedCommitTests.cs deleted file mode 100644 index 30030171..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_BaselineCommit_UsesProvidedCommitTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewWithDeltaAsync_BaselineCommit_UsesProvidedCommitTests - { - private Mock _mockInnerReviewer; - private Mock _mockLogger; - private Mock _mockGitService; - private ReviewCacheService _cacheService; - private CachingCodeReviewer _cachingReviewer; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _cacheService = new ReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer(_mockInnerReviewer.Object, _cacheService, null, null, _mockLogger.Object, _mockGitService.Object, null); - } - - [TestMethod] - public async Task DeltaAsync_WithBaselineCommit_UsesProvidedCommitForFileContent() - { - var path = "test.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - var baselineCommit = "abc123"; - var expectedDelta = new DeltaResponseModel(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, baselineCommit)).Returns(oldCode); - - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - await _cachingReviewer.DeltaAsync(review, currentCode, null, null, default, baselineCommit); - - _mockGitService.Verify(g => g.GetFileContentForCommit(path, baselineCommit), Times.Once); - _mockGitService.Verify(g => g.GetFileContentForCommit(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task DeltaAsync_WithoutBaselineCommit_CallsSingleArgOverload() - { - var path = "test.cs"; - var review = new FileReviewModel { FilePath = path, Score = 8.0f, RawScore = "9.5" }; - var currentCode = "current code"; - var oldCode = "old code"; - var expectedDelta = new DeltaResponseModel(); - - _mockGitService.Setup(g => g.GetFileContentForCommit(path, null)).Returns(oldCode); - - _mockInnerReviewer - .Setup(r => r.DeltaAsync(review, currentCode, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - await _cachingReviewer.DeltaAsync(review, currentCode, null, null, default, null); - - _mockGitService.Verify(g => g.GetFileContentForCommit(path, null), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs deleted file mode 100644 index 81d083b5..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests.CachingCodeReviewerTests -{ - [TestClass] - public class ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests - { - private Mock _mockInnerReviewer = null!; - private Mock _mockLogger = null!; - private Mock _mockGitService = null!; - private ReviewCacheService _reviewCacheService = null!; - private BaselineReviewCacheService _baselineCacheService = null!; - private CachingCodeReviewer _cachingReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockInnerReviewer = new Mock(); - _mockLogger = new Mock(); - _mockGitService = new Mock(); - _reviewCacheService = new ReviewCacheService(new ConcurrentDictionary()); - _baselineCacheService = new BaselineReviewCacheService(new ConcurrentDictionary()); - _cachingReviewer = new CachingCodeReviewer( - _mockInnerReviewer.Object, - _reviewCacheService, - _baselineCacheService, - null, - _mockLogger.Object, - _mockGitService.Object, - null); - } - - [TestMethod] - public async Task Test() - { - var path = "test.cs"; - var content = "public class Test { }"; - var reviewWithNullRawScore = new FileReviewModel - { - FilePath = path, - Score = 8.0f, - RawScore = null, - }; - - _mockInnerReviewer - .Setup(r => r.ReviewAsync(path, content, false, It.IsAny(), It.IsAny())) - .ReturnsAsync(reviewWithNullRawScore); - - var (review, delta) = await _cachingReviewer.ReviewWithDeltaAsync(path, content); - - Assert.IsNotNull(review); - Assert.AreEqual(path, review.FilePath); - Assert.IsNull(review.RawScore); - Assert.IsNull(delta); - _mockInnerReviewer.Verify(r => r.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliCommandProviderTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliCommandProviderTests.cs deleted file mode 100644 index 3601a22e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliCommandProviderTests.cs +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Globalization; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Refactor; -using Codescene.VSExtension.Core.Models.Cli.Review; -using Moq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class CliCommandProviderTests - { - private const string TestFileName = "js"; - private const string TestReviewFilePath = "/home/user/project/src/app.js"; - private const string TestFileContent = "content"; - private const string TestCachePath = "/home/user/cache"; - - private CliCommandProvider _commandProvider; - private Mock _mockLogger; - - [TestInitialize] - public void Initialize() - { - _mockLogger = new Mock(); - _commandProvider = new CliCommandProvider(new CliObjectScoreCreator(_mockLogger.Object)); - } - - [TestMethod] - public void VersionCommand_ShouldReturnCorrectString() - { - var command = _commandProvider.VersionCommand; - - Assert.AreEqual("version --sha", command); - } - - [TestMethod] - public void GetReviewFileContentPayload_ReturnsCorrectJson() - { - var payload = _commandProvider.GetReviewFileContentPayload(TestReviewFilePath, TestFileContent, TestCachePath); - - Assert.AreEqual($"{{\"path\":\"{TestReviewFilePath}\",\"file-content\":\"{TestFileContent}\",\"cache-path\":\"{TestCachePath}\"}}", payload); - } - - [TestMethod] - public void GetRefactorWithCodeSmellsPayload_Without_Preflight_ReturnsCorrectJson() - { - var codeSmells = CreateTestCodeSmells(); - - var content = _commandProvider.GetRefactorWithCodeSmellsPayload(TestFileName, TestFileContent, TestCachePath, codeSmells); - - Assert.AreEqual($"{{\"code-smells\":[{{\"category\":\"{codeSmells.First().Category}\",\"details\":null,\"highlight-range\":null}}],\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":null,\"cache-path\":\"{TestCachePath}\"}}", content); - } - - [TestMethod] - public void GetRefactorWithCodeSmellsPayload_With_Preflight_ReturnsCorrectJson() - { - var codeSmells = CreateTestCodeSmells(); - var preflight = CreateTestPreflight(); - - var content = _commandProvider.GetRefactorWithCodeSmellsPayload(TestFileName, TestFileContent, TestCachePath, codeSmells, preflight); - - Assert.AreEqual($"{{\"code-smells\":[{{\"category\":\"{codeSmells.First().Category}\",\"details\":null,\"highlight-range\":null}}],\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":{{\"version\":0.0,\"file-types\":[\"{preflight.FileTypes.First()}\"],\"language-common\":null,\"language-specific\":null}},\"cache-path\":\"{TestCachePath}\"}}", content); - } - - [TestMethod] - public void GetRefactorWithDeltaResultPayload_Without_Preflight_ReturnsCorrectJson() - { - var deltaResult = CreateTestDeltaResult(); - - var content = _commandProvider.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, TestCachePath, deltaResult); - - var expectedNewScore = deltaResult.NewScore.ToString("0.0", CultureInfo.InvariantCulture); - var expectedOldScore = deltaResult.OldScore.ToString("0.0", CultureInfo.InvariantCulture); - Assert.AreEqual($"{{\"delta-result\":{{\"file-level-findings\":null,\"function-level-findings\":null,\"new-score\":{expectedNewScore},\"old-score\":{expectedOldScore},\"score-change\":0.0}},\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":null,\"cache-path\":\"{TestCachePath}\"}}", content); - } - - [TestMethod] - public void GetRefactorWithDeltaResultPayload_With_Preflight_ReturnsCorrectJson() - { - var deltaResult = CreateTestDeltaResult(); - var preflight = CreateTestPreflight(); - - var content = _commandProvider.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, TestCachePath, deltaResult, preflight); - - var expectedNewScore = deltaResult.NewScore.ToString("0.0", CultureInfo.InvariantCulture); - var expectedOldScore = deltaResult.OldScore.ToString("0.0", CultureInfo.InvariantCulture); - Assert.AreEqual($"{{\"delta-result\":{{\"file-level-findings\":null,\"function-level-findings\":null,\"new-score\":{expectedNewScore},\"old-score\":{expectedOldScore},\"score-change\":0.0}},\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":{{\"version\":0.0,\"file-types\":[\"{preflight.FileTypes.First()}\"],\"language-common\":null,\"language-specific\":null}},\"cache-path\":\"{TestCachePath}\"}}", content); - } - - [TestMethod] - [DataRow(false, "refactor preflight")] - [DataRow(true, "refactor preflight --force")] - public void GetPreflightSupportInformationCommand_ReturnsCorrectCommand(bool force, string expected) - { - var command = _commandProvider.GetPreflightSupportInformationCommand(force); - - Assert.AreEqual(expected, command); - } - - [TestMethod] - public void SendTelemetryCommand_ReturnsCorrectFormat() - { - var jsonEvent = "{\"event\":\"test\"}"; - - var command = _commandProvider.SendTelemetryCommand(jsonEvent); - - Assert.StartsWith("telemetry --event", command); - } - - [TestMethod] - public void SendTelemetryCommand_EscapesDoubleQuotes() - { - var jsonEvent = "{\"event\":\"test\"}"; - - var command = _commandProvider.SendTelemetryCommand(jsonEvent); - - Assert.AreEqual("telemetry --event \"{\\\"event\\\":\\\"test\\\"}\"", command); - } - - [TestMethod] - public void SendTelemetryCommand_EmptyJson_ReturnsCommandWithEmptyJson() - { - var jsonEvent = "{}"; - - var command = _commandProvider.SendTelemetryCommand(jsonEvent); - - Assert.AreEqual("telemetry --event \"{}\"", command); - } - - [TestMethod] - public void SendTelemetryCommand_ComplexJson_EscapesAllQuotes() - { - var jsonEvent = "{\"name\":\"test\",\"value\":\"data\"}"; - - var command = _commandProvider.SendTelemetryCommand(jsonEvent); - - Assert.AreEqual("telemetry --event \"{\\\"name\\\":\\\"test\\\",\\\"value\\\":\\\"data\\\"}\"", command); - } - - [TestMethod] - public void RefactorPostCommand_ReturnsRunCommandRefactor() - { - Assert.AreEqual("run-command refactor", _commandProvider.RefactorPostCommand); - } - - [TestMethod] - public void GetRefactorPostPayload_WithNullFnToRefactor_ThrowsArgumentNullException() - { - Assert.Throws(() => - _commandProvider.GetRefactorPostPayload(null, skipCache: false, token: "token")); - } - - [TestMethod] - public void GetRefactorPostPayload_WithNullOrEmptyToken_Throws() - { - var fnToRefactor = CreateFnToRefactor(); - - Assert.Throws(() => - _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: false, token: null)); - Assert.Throws(() => - _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: false, token: string.Empty)); - } - - [TestMethod] - public void GetRefactorPostPayload_WithoutSkipCache_IncludesTokenAndFnToRefactor() - { - var fnToRefactor = CreateFnToRefactor(); - var token = "test-token-123"; - - var payload = _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: false, token: token); - var json = JObject.Parse(payload); - - Assert.AreEqual(token, json["token"]?.ToString()); - Assert.IsNull(json["skip-cache"]); - Assert.IsNotNull(json["fn-to-refactor"]); - Assert.IsNull(json["fn-to-refactor-nippy-b64"]); - } - - [TestMethod] - public void GetRefactorPostPayload_WithSkipCache_IncludesSkipCacheFlag() - { - var fnToRefactor = CreateFnToRefactor(); - - var payload = _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: true, token: "token"); - var json = JObject.Parse(payload); - - Assert.IsTrue(json["skip-cache"]?.Value()); - } - - [TestMethod] - public void GetRefactorPostPayload_WithNippyB64_UsesFnToRefactorNippyB64Property() - { - var nippyB64 = "base64encodeddata"; - var fnToRefactor = CreateFnToRefactor(nippyB64: nippyB64); - - var payload = _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: false, token: "token"); - var json = JObject.Parse(payload); - - Assert.AreEqual(nippyB64, json["fn-to-refactor-nippy-b64"]?.ToString()); - Assert.IsNull(json["fn-to-refactor"]); - } - - [TestMethod] - public void GetRefactorPostPayload_WithAllOptions_IncludesExpectedProperties() - { - var nippyB64 = "encodeddata"; - var fnToRefactor = CreateFnToRefactor(nippyB64: nippyB64); - var token = "my-token"; - - var payload = _commandProvider.GetRefactorPostPayload(fnToRefactor, skipCache: true, token: token); - var json = JObject.Parse(payload); - - Assert.AreEqual(token, json["token"]?.ToString()); - Assert.IsTrue(json["skip-cache"]?.Value()); - Assert.AreEqual(nippyB64, json["fn-to-refactor-nippy-b64"]?.ToString()); - } - - [TestMethod] - [DataRow(null, DisplayName = "null cache path")] - [DataRow("", DisplayName = "empty cache path")] - [DataRow(" ", DisplayName = "whitespace cache path")] - public void GetReviewFileContentPayload_WithInvalidCachePath_OmitsCachePath(string cachePath) - { - var payload = _commandProvider.GetReviewFileContentPayload(TestFileName, TestFileContent, cachePath); - - Assert.AreEqual($"{{\"path\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"cache-path\":null}}", payload); - } - - [TestMethod] - [DataRow(null, DisplayName = "null cache path")] - [DataRow("", DisplayName = "empty cache path")] - [DataRow(" ", DisplayName = "whitespace cache path")] - public void GetRefactorWithCodeSmellsPayload_WithInvalidCachePath_OmitsCachePathFromJson(string cachePath) - { - var codeSmells = CreateTestCodeSmells(); - - var content = _commandProvider.GetRefactorWithCodeSmellsPayload(TestFileName, TestFileContent, cachePath, codeSmells); - - Assert.AreEqual($"{{\"code-smells\":[{{\"category\":\"{codeSmells.First().Category}\",\"details\":null,\"highlight-range\":null}}],\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":null,\"cache-path\":null}}", content); - } - - [TestMethod] - [DataRow(null, DisplayName = "null cache path")] - [DataRow("", DisplayName = "empty cache path")] - [DataRow(" ", DisplayName = "whitespace cache path")] - public void GetRefactorWithDeltaResultPayload_WithInvalidCachePath_OmitsCachePathFromJson(string cachePath) - { - var deltaResult = CreateTestDeltaResult(); - - var content = _commandProvider.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, cachePath, deltaResult); - - var expectedNewScore = deltaResult.NewScore.ToString("0.0", CultureInfo.InvariantCulture); - var expectedOldScore = deltaResult.OldScore.ToString("0.0", CultureInfo.InvariantCulture); - Assert.AreEqual($"{{\"delta-result\":{{\"file-level-findings\":null,\"function-level-findings\":null,\"new-score\":{expectedNewScore},\"old-score\":{expectedOldScore},\"score-change\":0.0}},\"file-name\":\"{TestFileName}\",\"file-content\":\"{TestFileContent}\",\"preflight\":null,\"cache-path\":null}}", content); - } - - private static List CreateTestCodeSmells() - { - return new List { new CliCodeSmellModel { Category = "test" } }; - } - - private static PreFlightResponseModel CreateTestPreflight() - { - return new PreFlightResponseModel { FileTypes = new[] { ".js" } }; - } - - private static DeltaResponseModel CreateTestDeltaResult() - { - return new DeltaResponseModel { NewScore = 2, OldScore = 3 }; - } - - private static FnToRefactorModel CreateFnToRefactor(string name = "TestFunction", string? nippyB64 = null) - { - return new FnToRefactorModel - { - Name = name, - Body = "function body", - FileType = "cs", - NippyB64 = nippyB64, - }; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorRefactorDeviceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorRefactorDeviceTests.cs index 377a45db..09e221c3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorRefactorDeviceTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorRefactorDeviceTests.cs @@ -8,8 +8,8 @@ using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; using Moq; -using Newtonsoft.Json; namespace Codescene.VSExtension.Core.Tests { @@ -21,9 +21,7 @@ public class CliExecutorRefactorDeviceTests private const string TestFileContent = "public class Test { }"; private Mock _mockLogger; - private Mock _mockCliServices; - private Mock _mockCommandProvider; - private Mock _mockProcessExecutor; + private Mock _mockClient; private Mock _mockCacheStorage; private Mock _mockSettingsProvider; private CliExecutor _cliExecutor; @@ -32,19 +30,11 @@ public class CliExecutorRefactorDeviceTests public void Setup() { _mockLogger = new Mock(); - _mockCommandProvider = new Mock(); - _mockProcessExecutor = new Mock(); + _mockClient = new Mock(); _mockCacheStorage = new Mock(); _mockSettingsProvider = new Mock(); - - _mockCliServices = new Mock(); - _mockCliServices.Setup(x => x.CommandProvider).Returns(_mockCommandProvider.Object); - _mockCliServices.Setup(x => x.ProcessExecutor).Returns(_mockProcessExecutor.Object); - _mockCliServices.Setup(x => x.CacheStorage).Returns(_mockCacheStorage.Object); _mockCacheStorage.Setup(x => x.GetSolutionReviewCacheLocation()).Returns(TestCachePath); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - - _cliExecutor = new CliExecutor(_mockLogger.Object, _mockCliServices.Object, _mockSettingsProvider.Object, null); + _cliExecutor = new CliExecutor(_mockLogger.Object, _mockClient.Object, _mockCacheStorage.Object, _mockSettingsProvider.Object, null); } [TestMethod] @@ -52,18 +42,21 @@ public async Task PostRefactoring_WithValidResponse_ReturnsRefactorResponseModel { var fnToRefactor = new FnToRefactorModel { Name = "TestMethod", Body = "public void Test() { }", FileType = "cs" }; var expectedResponse = new RefactorResponseModel { Code = "public void Test() { /* refactored */ }", TraceId = "trace-123" }; - var jsonResponse = JsonConvert.SerializeObject(expectedResponse); var token = "test-token"; _mockSettingsProvider.Setup(x => x.AuthToken).Returns(token); - _mockCommandProvider.Setup(x => x.RefactorPostCommand).Returns("run-command refactor"); - _mockCommandProvider.Setup(x => x.GetRefactorPostPayload(fnToRefactor, false, token)).Returns("{\"token\":\"test-token\"}"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("run-command refactor", It.IsAny(), null, default, It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.RefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResponse); var result = await _cliExecutor.PostRefactoringAsync(fnToRefactor, skipCache: false); Assert.IsNotNull(result); Assert.AreEqual(expectedResponse.Code, result.Code); Assert.AreEqual(expectedResponse.TraceId, result.TraceId); + _mockClient.Verify( + x => x.RefactorAsync( + It.Is(r => r.Token == token && r.FnToRefactor == fnToRefactor && r.SkipCache == null), + It.IsAny()), + Times.Once); } [TestMethod] @@ -87,39 +80,26 @@ public async Task PostRefactoring_WithProvidedToken_UsesProvidedToken() var fnToRefactor = new FnToRefactorModel { Name = "Test" }; var providedToken = "provided-token"; var response = new RefactorResponseModel { Code = "refactored" }; - var jsonResponse = JsonConvert.SerializeObject(response); - _mockCommandProvider.Setup(x => x.RefactorPostCommand).Returns("run-command refactor"); - _mockCommandProvider.Setup(x => x.GetRefactorPostPayload(fnToRefactor, false, providedToken)).Returns("{\"token\":\"provided-token\"}"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.RefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(response); var result = await _cliExecutor.PostRefactoringAsync(fnToRefactor, skipCache: false, token: providedToken); Assert.IsNotNull(result); - _mockCommandProvider.Verify(x => x.GetRefactorPostPayload(fnToRefactor, false, providedToken), Times.Once); - _mockProcessExecutor.Verify(x => x.ExecuteAsync("run-command refactor", It.IsAny(), null, It.IsAny(), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task PostRefactoring_WithEmptyArguments_ReturnsNull() - { - var fnToRefactor = new FnToRefactorModel { Name = "Test" }; - _mockSettingsProvider.Setup(x => x.AuthToken).Returns("test-token"); - _mockCommandProvider.Setup(x => x.RefactorPostCommand).Returns(string.Empty); - - var result = await _cliExecutor.PostRefactoringAsync(fnToRefactor); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping refactoring. Arguments were not defined."), Times.Once); + _mockClient.Verify( + x => x.RefactorAsync( + It.Is(r => r.Token == providedToken && r.FnToRefactor == fnToRefactor), + It.IsAny()), + Times.Once); } [TestMethod] - public async Task PostRefactoring_WhenProcessExecutorThrowsException_ReturnsNull() + public async Task PostRefactoring_WhenClientThrowsException_ReturnsNull() { var fnToRefactor = new FnToRefactorModel { Name = "Test" }; _mockSettingsProvider.Setup(x => x.AuthToken).Returns("test-token"); - _mockCommandProvider.Setup(x => x.RefactorPostCommand).Returns("run-command refactor"); - _mockCommandProvider.Setup(x => x.GetRefactorPostPayload(It.IsAny(), It.IsAny(), It.IsAny())).Returns("{\"token\":\"test-token\"}"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("Error")); + _mockClient.Setup(x => x.RefactorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Error")); var result = await _cliExecutor.PostRefactoringAsync(fnToRefactor); @@ -133,16 +113,18 @@ public async Task PostRefactoring_WithSkipCache_PassesSkipCacheFlag() var fnToRefactor = new FnToRefactorModel { Name = "Test" }; var token = "test-token"; var response = new RefactorResponseModel { Code = "refactored" }; - var jsonResponse = JsonConvert.SerializeObject(response); _mockSettingsProvider.Setup(x => x.AuthToken).Returns(token); - _mockCommandProvider.Setup(x => x.RefactorPostCommand).Returns("run-command refactor"); - _mockCommandProvider.Setup(x => x.GetRefactorPostPayload(fnToRefactor, true, token)).Returns("{\"token\":\"test-token\",\"skip-cache\":true}"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.RefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(response); var result = await _cliExecutor.PostRefactoringAsync(fnToRefactor, skipCache: true); Assert.IsNotNull(result); - _mockCommandProvider.Verify(x => x.GetRefactorPostPayload(fnToRefactor, true, token), Times.Once); + _mockClient.Verify( + x => x.RefactorAsync( + It.Is(r => r.SkipCache == true && r.Token == token), + It.IsAny()), + Times.Once); } [TestMethod] @@ -151,10 +133,8 @@ public async Task FnsToRefactorFromCodeSmells_WithValidResponse_ReturnsListOfFnT var codeSmells = new List { new CliCodeSmellModel { Category = "Complex Method" } }; var preflight = new PreFlightResponseModel { Version = 1.0m }; var expectedFunctions = new List { new FnToRefactorModel { Name = "Function1", Body = "code" } }; - var jsonResponse = JsonConvert.SerializeObject(expectedFunctions); - _mockCommandProvider.Setup(x => x.GetRefactorWithCodeSmellsPayload(TestFileName, TestFileContent, TestCachePath, codeSmells, preflight)).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("refactor", "payload", null, default, It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedFunctions); var result = await _cliExecutor.FnsToRefactorFromCodeSmellsAsync(TestFileName, TestFileContent, codeSmells, preflight); @@ -181,26 +161,12 @@ public async Task FnsToRefactorFromCodeSmells_WithEmptyCodeSmells_ReturnsNull() _mockLogger.Verify(x => x.Debug("Skipping refactoring functions from code smells. Code smells list was null or empty."), Times.Once); } - [TestMethod] - public async Task FnsToRefactorFromCodeSmells_WithEmptyPayload_ReturnsNull() - { - var codeSmells = new List { new CliCodeSmellModel { Category = "Test" } }; - _mockCommandProvider.Setup(x => x.GetRefactorWithCodeSmellsPayload(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())).Returns(string.Empty); - - var result = await _cliExecutor.FnsToRefactorFromCodeSmellsAsync(TestFileName, TestFileContent, codeSmells, null); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping refactoring functions check. Payload content was not defined."), Times.Once); - } - [TestMethod] public async Task FnsToRefactorFromCodeSmells_RemovesOldCacheEntries() { var codeSmells = new List { new CliCodeSmellModel { Category = "Test" } }; - var jsonResponse = JsonConvert.SerializeObject(new List()); - _mockCommandProvider.Setup(x => x.GetRefactorWithCodeSmellsPayload(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); await _cliExecutor.FnsToRefactorFromCodeSmellsAsync(TestFileName, TestFileContent, codeSmells, null); @@ -213,10 +179,8 @@ public async Task FnsToRefactorFromDelta_WithValidResponse_ReturnsListOfFnToRefa var deltaResult = new DeltaResponseModel { NewScore = 8.0m, OldScore = 7.0m }; var preflight = new PreFlightResponseModel { Version = 1.0m }; var expectedFunctions = new List { new FnToRefactorModel { Name = "Function1", Body = "code" } }; - var jsonResponse = JsonConvert.SerializeObject(expectedFunctions); - _mockCommandProvider.Setup(x => x.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, TestCachePath, deltaResult, preflight)).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("refactor", "payload", null, default, It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedFunctions); var result = await _cliExecutor.FnsToRefactorFromDeltaAsync(TestFileName, TestFileContent, deltaResult, preflight); @@ -234,26 +198,12 @@ public async Task FnsToRefactorFromDelta_WithNullDeltaResult_ReturnsNull() _mockLogger.Verify(x => x.Debug("Skipping refactoring functions from delta. Delta result was null."), Times.Once); } - [TestMethod] - public async Task FnsToRefactorFromDelta_WithEmptyPayload_ReturnsNull() - { - var deltaResult = new DeltaResponseModel { NewScore = 8.0m, OldScore = 7.0m }; - _mockCommandProvider.Setup(x => x.GetRefactorWithDeltaResultPayload(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(string.Empty); - - var result = await _cliExecutor.FnsToRefactorFromDeltaAsync(TestFileName, TestFileContent, deltaResult, null); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping refactoring functions check. Payload content was not defined."), Times.Once); - } - [TestMethod] public async Task FnsToRefactorFromDelta_RemovesOldCacheEntries() { var deltaResult = new DeltaResponseModel { NewScore = 8.0m, OldScore = 7.0m }; - var jsonResponse = JsonConvert.SerializeObject(new List()); - _mockCommandProvider.Setup(x => x.GetRefactorWithDeltaResultPayload(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); await _cliExecutor.FnsToRefactorFromDeltaAsync(TestFileName, TestFileContent, deltaResult, null); @@ -261,22 +211,19 @@ public async Task FnsToRefactorFromDelta_RemovesOldCacheEntries() } [TestMethod] - public async Task FnsToRefactorFromDelta_ConcurrentIdenticalRequests_CallsProcessExecutorOnce() + public async Task FnsToRefactorFromDelta_ConcurrentIdenticalRequests_CallsClientOnce() { var deltaResult = new DeltaResponseModel { NewScore = 8.0m, OldScore = 7.0m }; var functions = new List { new FnToRefactorModel { Name = "Function1", Body = "code" } }; - var jsonResponse = JsonConvert.SerializeObject(functions); var completion = new TaskCompletionSource(); var callCount = 0; - _mockCommandProvider.Setup(x => x.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, TestCachePath, deltaResult, null)).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("refactor", "payload", null, It.IsAny(), It.IsAny())) - .Returns(async (_, _, _, _, __) => + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .Returns(async (_, __) => { Interlocked.Increment(ref callCount); await completion.Task; - return jsonResponse; + return functions; }); var firstTask = _cliExecutor.FnsToRefactorFromDeltaAsync(TestFileName, TestFileContent, deltaResult, null); @@ -299,15 +246,12 @@ public async Task FnsToRefactorFromDelta_CanceledCallerDoesNotCancelSharedReques { var deltaResult = new DeltaResponseModel { NewScore = 8.0m, OldScore = 7.0m }; var functions = new List { new FnToRefactorModel { Name = "Function1", Body = "code" } }; - var jsonResponse = JsonConvert.SerializeObject(functions); var entered = new TaskCompletionSource(); - var completion = new TaskCompletionSource(); + var completion = new TaskCompletionSource>(); var capturedToken = CancellationToken.None; - _mockCommandProvider.Setup(x => x.GetRefactorWithDeltaResultPayload(TestFileName, TestFileContent, TestCachePath, deltaResult, null)).Returns("payload"); - _mockCommandProvider.Setup(x => x.RefactorCommand).Returns("refactor"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("refactor", "payload", null, It.IsAny(), It.IsAny())) - .Returns((_, _, _, token, __) => + _mockClient.Setup(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny())) + .Returns((_, token) => { capturedToken = token; entered.TrySetResult(true); @@ -323,31 +267,28 @@ public async Task FnsToRefactorFromDelta_CanceledCallerDoesNotCancelSharedReques await Assert.ThrowsAsync(() => canceledCallerTask); - completion.TrySetResult(jsonResponse); + completion.TrySetResult(functions); var secondResult = await secondCallerTask; Assert.HasCount(1, secondResult); Assert.AreEqual(CancellationToken.None, capturedToken); - _mockProcessExecutor.Verify(x => x.ExecuteAsync("refactor", "payload", null, It.IsAny(), It.IsAny()), Times.Once); + _mockClient.Verify(x => x.FnsToRefactorAsync(It.IsAny(), It.IsAny()), Times.Once); } [TestMethod] public async Task GetDeviceId_WithValidResponse_ReturnsDeviceId() { - var expectedDeviceId = "device-id-123"; - _mockCommandProvider.Setup(x => x.DeviceIdCommand).Returns("device-id"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("device-id", null, null, default, It.IsAny())).ReturnsAsync(expectedDeviceId); + _mockClient.Setup(x => x.DeviceIdAsync(It.IsAny())).ReturnsAsync("device-id-123"); var result = await _cliExecutor.GetDeviceIdAsync(); - Assert.AreEqual(expectedDeviceId, result); + Assert.AreEqual("device-id-123", result); } [TestMethod] - public async Task GetDeviceId_WhenProcessExecutorThrowsException_ReturnsEmptyString() + public async Task GetDeviceId_WhenClientThrowsException_ReturnsEmptyString() { - _mockCommandProvider.Setup(x => x.DeviceIdCommand).Returns("device-id"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("device-id", null, null, default, It.IsAny())).ThrowsAsync(new Exception("Error")); + _mockClient.Setup(x => x.DeviceIdAsync(It.IsAny())).ThrowsAsync(new Exception("Error")); var result = await _cliExecutor.GetDeviceIdAsync(); @@ -358,9 +299,7 @@ public async Task GetDeviceId_WhenProcessExecutorThrowsException_ReturnsEmptyStr [TestMethod] public async Task GetDeviceId_TrimsWhitespace() { - var deviceIdWithWhitespace = " device-id-123 \r\n"; - _mockCommandProvider.Setup(x => x.DeviceIdCommand).Returns("device-id"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("device-id", null, null, default, It.IsAny())).ReturnsAsync(deviceIdWithWhitespace); + _mockClient.Setup(x => x.DeviceIdAsync(It.IsAny())).ReturnsAsync(" device-id-123 \r\n"); var result = await _cliExecutor.GetDeviceIdAsync(); @@ -370,20 +309,18 @@ public async Task GetDeviceId_TrimsWhitespace() [TestMethod] public async Task GetFileVersion_WithValidResponse_ReturnsVersion() { - var expectedVersion = "1.2.3"; - _mockCommandProvider.Setup(x => x.VersionCommand).Returns("version --sha"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("version --sha", null, null, default, It.IsAny())).ReturnsAsync(expectedVersion); + _mockClient.Setup(x => x.StartAsync(It.IsAny())) + .ReturnsAsync(new ServerMetadata { Sha = "1.2.3" }); var result = await _cliExecutor.GetFileVersionAsync(); - Assert.AreEqual(expectedVersion, result); + Assert.AreEqual("1.2.3", result); } [TestMethod] - public async Task GetFileVersion_WhenProcessExecutorThrowsException_ReturnsEmptyString() + public async Task GetFileVersion_WhenClientThrowsException_ReturnsEmptyString() { - _mockCommandProvider.Setup(x => x.VersionCommand).Returns("version --sha"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("version --sha", null, null, default, It.IsAny())).ThrowsAsync(new Exception("Error")); + _mockClient.Setup(x => x.StartAsync(It.IsAny())).ThrowsAsync(new Exception("Error")); var result = await _cliExecutor.GetFileVersionAsync(); @@ -394,9 +331,8 @@ public async Task GetFileVersion_WhenProcessExecutorThrowsException_ReturnsEmpty [TestMethod] public async Task GetFileVersion_TrimsWhitespace() { - var versionWithWhitespace = " 1.2.3 \r\n"; - _mockCommandProvider.Setup(x => x.VersionCommand).Returns("version --sha"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("version --sha", null, null, default, It.IsAny())).ReturnsAsync(versionWithWhitespace); + _mockClient.Setup(x => x.StartAsync(It.IsAny())) + .ReturnsAsync(new ServerMetadata { Sha = " 1.2.3 \r\n" }); var result = await _cliExecutor.GetFileVersionAsync(); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs index ee05a7aa..5b0f7d20 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs @@ -1,20 +1,13 @@ // Copyright (c) CodeScene. All rights reserved. -using System.Reflection; using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Exceptions; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Cli; using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Interfaces.Util; -using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; using Moq; -using Newtonsoft.Json; namespace Codescene.VSExtension.Core.Tests { @@ -22,13 +15,9 @@ namespace Codescene.VSExtension.Core.Tests public class CliExecutorTests { private const string TestCachePath = "/test/cache/path"; - private const string TestFileContent = "public class Test { }"; - private static readonly string TestFilePath = $"{TestCachePath}/test.cs"; private Mock _mockLogger; - private Mock _mockCliServices; - private Mock _mockCommandProvider; - private Mock _mockProcessExecutor; + private Mock _mockClient; private Mock _mockCacheStorage; private Mock _mockSettingsProvider; private Mock _mockTelemetryManager; @@ -39,190 +28,26 @@ public class CliExecutorTests public void Setup() { _mockLogger = new Mock(); - _mockCommandProvider = new Mock(); - _mockProcessExecutor = new Mock(); + _mockClient = new Mock(); _mockCacheStorage = new Mock(); _mockSettingsProvider = new Mock(); _mockTelemetryManager = new Mock(); _lazyTelemetryManager = new Lazy(() => _mockTelemetryManager.Object); - - _mockCliServices = new Mock(); - _mockCliServices.Setup(x => x.CommandProvider).Returns(_mockCommandProvider.Object); - _mockCliServices.Setup(x => x.ProcessExecutor).Returns(_mockProcessExecutor.Object); - _mockCliServices.Setup(x => x.CacheStorage).Returns(_mockCacheStorage.Object); - _mockCacheStorage.Setup(x => x.GetSolutionReviewCacheLocation()).Returns(TestCachePath); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - _cliExecutor = new CliExecutor( _mockLogger.Object, - _mockCliServices.Object, + _mockClient.Object, + _mockCacheStorage.Object, _mockSettingsProvider.Object, _lazyTelemetryManager); } - [TestMethod] - public async Task ReviewContentAsync_WithValidResponse_ReturnsCliReviewModel() - { - var expectedReview = new CliReviewModel - { - Score = 7.5f, - RawScore = "base64encoded", - }; - var jsonResponse = JsonConvert.SerializeObject(expectedReview); - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .ReturnsAsync(jsonResponse); - - var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedReview.Score, result.Score); - Assert.AreEqual(expectedReview.RawScore, result.RawScore); - _mockProcessExecutor.Verify(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsDevtoolsException_ThrowsException() - { - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .ThrowsAsync(new DevtoolsException("CLI error", 500, "trace-123")); - - var exception = await Assert.ThrowsAsync(() => - _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent)); - Assert.AreEqual("CLI error", exception.Message); - _mockLogger.Verify(x => x.Error(It.Is(s => s.Contains("Review of file")), It.IsAny()), Times.Once); - _mockLogger.Verify(x => x.Warn(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsRefactoringCreditsDevtoolsException_LogsWarningNotError() - { - const string creditsMessage = - "Your credits of refactoring functionality ran out. Buy a bigger plan."; - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .ThrowsAsync(new DevtoolsException(creditsMessage, 402, "trace-xyz")); - - var exception = await Assert.ThrowsAsync(() => - _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent)); - Assert.AreEqual(creditsMessage, exception.Message); - _mockLogger.Verify( - x => x.Warn(It.Is(s => s.Contains("Review of file") && s.Contains(creditsMessage) && s.Contains("402") && s.Contains("trace-xyz")), It.IsAny()), - Times.Once); - _mockLogger.Verify(x => x.Error(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsGenericException_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("Generic error")); - var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Error(It.Is(s => s.Contains("Review of file")), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task ReviewContentAsync_WithInvalidJson_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .ReturnsAsync("invalid json"); - var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); - - Assert.IsNull(result); - } - - [TestMethod] - public async Task ReviewDeltaAsync_WithValidResponse_ReturnsDeltaResponseModel() - { - var oldScore = "old-score-b64"; - var newScore = "new-score-b64"; - var expectedDelta = new DeltaResponseModel - { - NewScore = 8.5m, - OldScore = 7.0m, - ScoreChange = 1.5m, - }; - var jsonResponse = JsonConvert.SerializeObject(expectedDelta); - _mockCommandProvider.Setup(x => x.GetReviewDeltaCommand(oldScore, newScore)) - .Returns("delta command"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(jsonResponse); - - var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = oldScore, NewScore = newScore, FilePath = TestFilePath, FileContent = TestFileContent }); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedDelta.NewScore, result.NewScore); - Assert.AreEqual(expectedDelta.OldScore, result.OldScore); - Assert.AreEqual(expectedDelta.ScoreChange, result.ScoreChange); - } - - [TestMethod] - public async Task ReviewDeltaAsync_WithEmptyArguments_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.GetReviewDeltaCommand(It.IsAny(), It.IsAny())) - .Returns(string.Empty); - - var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old", NewScore = "new" }); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping delta review. Arguments were not defined."), Times.Once); - } - - [TestMethod] - public async Task ReviewDeltaAsync_WithNullArguments_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.GetReviewDeltaCommand(It.IsAny(), It.IsAny())) - .Returns((string)null); - - var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old", NewScore = "new" }); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping delta review. Arguments were not defined."), Times.Once); - } - - [TestMethod] - public async Task ReviewDeltaAsync_WhenProcessExecutorThrowsException_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.GetReviewDeltaCommand(It.IsAny(), It.IsAny())) - .Returns("delta command"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("Error")); - - var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old", NewScore = "new" }); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Error(It.Is(s => s.Contains("Delta for file failed")), It.IsAny()), Times.Once); - } - [TestMethod] public async Task PreflightAsync_WithValidResponse_ReturnsPreFlightResponseModel() { - var expectedPreflight = new PreFlightResponseModel - { - Version = 1.0m, - FileTypes = new[] { ".cs", ".js" }, - }; - var jsonResponse = JsonConvert.SerializeObject(expectedPreflight); - _mockCommandProvider.Setup(x => x.GetPreflightSupportInformationCommand(It.IsAny())) - .Returns("refactor preflight --force"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(jsonResponse); + var expectedPreflight = new PreFlightResponseModel { Version = 1.0m, FileTypes = new[] { ".cs", ".js" } }; + _mockClient.Setup(x => x.PreflightAsync(true, It.IsAny())) + .ReturnsAsync(expectedPreflight); var result = await _cliExecutor.PreflightAsync(force: true); @@ -232,23 +57,9 @@ public async Task PreflightAsync_WithValidResponse_ReturnsPreFlightResponseModel } [TestMethod] - public async Task PreflightAsync_WithEmptyArguments_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.GetPreflightSupportInformationCommand(It.IsAny())) - .Returns(string.Empty); - - var result = await _cliExecutor.PreflightAsync(); - - Assert.IsNull(result); - _mockLogger.Verify(x => x.Warn("Skipping preflight. Arguments were not defined."), Times.Once); - } - - [TestMethod] - public async Task PreflightAsync_WhenProcessExecutorThrowsException_ReturnsNull() + public async Task PreflightAsync_WhenClientThrowsException_ReturnsNull() { - _mockCommandProvider.Setup(x => x.GetPreflightSupportInformationCommand(It.IsAny())) - .Returns("refactor preflight"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _mockClient.Setup(x => x.PreflightAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Error")); var result = await _cliExecutor.PreflightAsync(); @@ -258,33 +69,30 @@ public async Task PreflightAsync_WhenProcessExecutorThrowsException_ReturnsNull( } [TestMethod] - public async Task PreflightAsync_WithForceFalse_UsesCorrectCommand() + public async Task PreflightAsync_WithForceFalse_UsesCorrectFlag() { var preflight = new PreFlightResponseModel { Version = 1.0m }; - var jsonResponse = JsonConvert.SerializeObject(preflight); - _mockCommandProvider.Setup(x => x.GetPreflightSupportInformationCommand(false)) - .Returns("refactor preflight"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(jsonResponse); + _mockClient.Setup(x => x.PreflightAsync(false, It.IsAny())) + .ReturnsAsync(preflight); var result = await _cliExecutor.PreflightAsync(force: false); Assert.IsNotNull(result); - _mockCommandProvider.Verify(x => x.GetPreflightSupportInformationCommand(false), Times.Once); + _mockClient.Verify(x => x.PreflightAsync(false, It.IsAny()), Times.Once); } [TestMethod] public void Constructor_WithNullLogger_ThrowsArgumentNullException() { Assert.Throws(() => - new CliExecutor(null, _mockCliServices.Object, _mockSettingsProvider.Object)); + new CliExecutor(null, _mockClient.Object, _mockCacheStorage.Object, _mockSettingsProvider.Object)); } [TestMethod] - public void Constructor_WithNullCliServices_ThrowsArgumentNullException() + public void Constructor_WithNullClient_ThrowsArgumentNullException() { Assert.Throws(() => - new CliExecutor(_mockLogger.Object, null, _mockSettingsProvider.Object)); + new CliExecutor(_mockLogger.Object, null, _mockCacheStorage.Object, _mockSettingsProvider.Object)); } [TestMethod] @@ -292,373 +100,11 @@ public void Constructor_WithNullTelemetryManagerLazy_DoesNotThrow() { var executor = new CliExecutor( _mockLogger.Object, - _mockCliServices.Object, + _mockClient.Object, + _mockCacheStorage.Object, _mockSettingsProvider.Object); Assert.IsNotNull(executor); } - - [TestMethod] - public async Task ReviewContentAsync_WhenCancelled_ReturnsNull() - { - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - var completion = new TaskCompletionSource(); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .Returns((_, _, _, ct, __) => - { - ct.Register(() => completion.TrySetCanceled(ct)); - return completion.Task; - }); - - var cts = new CancellationTokenSource(); - var task = _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent, false, cts.Token); - cts.Cancel(); - - var result = await task; - - Assert.IsNull(result); - } - - [TestMethod] - public async Task ReviewContentAsync_WhenSecondCallCancelsFirst_FirstReturnsNull() - { - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name test.cs"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(TestFilePath, TestFileContent, TestCachePath)) - .Returns("payload"); - var firstCompletion = new TaskCompletionSource(); - var callCount = 0; - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name test.cs", "payload", null, It.IsAny(), It.IsAny())) - .Returns((cmd, payload, timeout, ct, _) => - { - callCount++; - if (callCount == 1) - { - return firstCompletion.Task; - } - - return Task.FromResult(JsonConvert.SerializeObject(new CliReviewModel { Score = 7.5f, RawScore = "raw" })); - }); - - var firstTask = _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent, false); - await Task.Delay(50); - var secondTask = _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent, false); - firstCompletion.SetCanceled(); - - var firstResult = await firstTask; - var secondResult = await secondTask; - - Assert.IsNull(firstResult); - Assert.IsNotNull(secondResult); - } - - [TestMethod] - public async Task ReviewContentAsync_ConcurrentDifferentFiles_UsesBoundedConcurrency() - { - var firstFile = $"{TestCachePath}/first.cs"; - var secondFile = $"{TestCachePath}/second.cs"; - var completion = new TaskCompletionSource(); - var startedSignal = new TaskCompletionSource(); - var callCount = 0; - - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review --file-name"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(It.IsAny(), It.IsAny(), TestCachePath)) - .Returns((string filePath, string _, string _) => "payload-" + filePath); - _mockProcessExecutor.Setup(x => x.ExecuteAsync("review --file-name", It.IsAny(), null, It.IsAny(), It.IsAny())) - .Returns(async (_, payload, _, _, __) => - { - Interlocked.Increment(ref callCount); - startedSignal.TrySetResult(true); - await completion.Task; - return JsonConvert.SerializeObject(new CliReviewModel { Score = 7.5f, RawScore = payload }); - }); - - var firstTask = _cliExecutor.ReviewContentAsync(firstFile, TestFileContent); - await startedSignal.Task; - var secondTask = _cliExecutor.ReviewContentAsync(secondFile, TestFileContent); - await Task.Delay(100); - - Assert.AreEqual(1, callCount, "Second review should wait for the shared CLI channel."); - - completion.TrySetResult(true); - var firstResult = await firstTask; - var secondResult = await secondTask; - - Assert.IsNotNull(firstResult); - Assert.IsNotNull(secondResult); - Assert.AreEqual(2, callCount); - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_NullOrWhitespace_ReturnsNullWhenNoWorkspace() - { - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.IsNotNull(method); - var rNull = (string)method.Invoke(_cliExecutor, new object[] { null }); - var rBlank = (string)method.Invoke(_cliExecutor, new object[] { " \t " }); - Assert.IsNull(rNull); - Assert.IsNull(rBlank); - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_NullOrWhitespace_ReturnsNormalizedWorkspaceWhenConfigured() - { - var ws = Path.Combine(Path.GetTempPath(), "cli-ws-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(ws); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(ws); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - var r = (string)method.Invoke(_cliExecutor, new object[] { null }); - Assert.AreEqual(PathNormalization.NormalizeWorkingDirectory(ws), r); - } - finally - { - if (Directory.Exists(ws)) - { - Directory.Delete(ws, true); - } - } - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_FileInsideGitRepo_ReturnsNormalizedRepoRoot() - { - var repoPath = Path.Combine(Path.GetTempPath(), "cli-git-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(repoPath); - Repository.Init(repoPath); - var fileInRepo = Path.Combine(repoPath, "file.cs"); - File.WriteAllText(fileInRepo, "//"); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - var wd = (string)method.Invoke(_cliExecutor, new object[] { fileInRepo }); - Assert.AreEqual(PathNormalization.NormalizeWorkingDirectory(Path.GetFullPath(repoPath)), wd); - } - finally - { - if (Directory.Exists(repoPath)) - { - Directory.Delete(repoPath, true); - } - } - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_FileOutsideGitWithExistingWorkspace_ReturnsWorkspace() - { - var outside = Path.Combine(Path.GetTempPath(), "cli-out-" + Guid.NewGuid().ToString("N")); - var ws = Path.Combine(Path.GetTempPath(), "cli-ws2-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(outside); - Directory.CreateDirectory(ws); - var filePath = Path.Combine(outside, "orphan.cs"); - File.WriteAllText(filePath, "//"); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(ws); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - var wd = (string)method.Invoke(_cliExecutor, new object[] { filePath }); - Assert.AreEqual(PathNormalization.NormalizeWorkingDirectory(ws), wd); - } - finally - { - if (Directory.Exists(outside)) - { - Directory.Delete(outside, true); - } - - if (Directory.Exists(ws)) - { - Directory.Delete(ws, true); - } - } - } - - [TestMethod] - public void GetReviewCancellationPathIdentity_WhenGetFullPathThrows_ReturnsOriginalPath() - { - var method = typeof(CliExecutor).GetMethod("GetReviewCancellationPathIdentity", BindingFlags.NonPublic | BindingFlags.Static); - Assert.IsNotNull(method); - var tooLong = new string('a', 40000); - var result = (string)method.Invoke(null, new object[] { tooLong }); - Assert.AreEqual(tooLong, result); - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_WhenGetFullPathThrows_UsesPathForFallback() - { - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.IsNotNull(method); - var pathWithIllegalName = Path.Combine(Path.GetTempPath(), "bad*name.cs"); - var wd = (string)method.Invoke(_cliExecutor, new object[] { pathWithIllegalName }); - Assert.AreEqual(Path.GetDirectoryName(pathWithIllegalName), wd); - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_BareRepository_SkipsNormalizeWhenWorkingDirectoryEmpty() - { - var bareRepoPath = Path.Combine(Path.GetTempPath(), "cli-bare-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(bareRepoPath); - Repository.Init(bareRepoPath, isBare: true); - var fileInBare = Path.Combine(bareRepoPath, "marker.cs"); - File.WriteAllText(fileInBare, "//"); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.IsNotNull(method); - var wd = (string)method.Invoke(_cliExecutor, new object[] { fileInBare }); - var expectedDir = Path.GetDirectoryName(Path.GetFullPath(fileInBare)); - Assert.AreEqual(expectedDir, wd); - } - finally - { - if (Directory.Exists(bareRepoPath)) - { - Directory.Delete(bareRepoPath, true); - } - } - } - - [TestMethod] - public void GetCliWorkingDirectoryForFile_WhenGitResolutionFails_LogsDebugAndUsesFileDirectory() - { - var bad = Path.Combine(Path.GetTempPath(), "cli-badgit-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(bad); - File.WriteAllText(Path.Combine(bad, ".git"), "invalid"); - var fp = Path.Combine(bad, "x.cs"); - File.WriteAllText(fp, "//"); - _mockCacheStorage.Setup(x => x.GetWorkspaceDirectory()).Returns(string.Empty); - var method = typeof(CliExecutor).GetMethod("GetCliWorkingDirectoryForFile", BindingFlags.NonPublic | BindingFlags.Instance); - var wd = (string)method.Invoke(_cliExecutor, new object[] { fp }); - var expectedDir = Path.GetDirectoryName(Path.GetFullPath(fp)); - Assert.AreEqual(expectedDir, wd); - _mockLogger.Verify(x => x.Debug(It.Is(s => s.Contains("Could not resolve git working directory"))), Times.Once); - } - finally - { - if (Directory.Exists(bad)) - { - Directory.Delete(bad, true); - } - } - } - - [TestMethod] - public async Task ExecuteOnChannelAsync_AcquiresSemaphoreBeforeWaitingForCpu() - { - var operationLog = new List(); - var semaphoreAcquiredSignal = new TaskCompletionSource(); - var cpuCheckSignal = new TaskCompletionSource(); - - var mockThrottler = new Mock(); - mockThrottler.Setup(x => x.WaitForCpuAsync(It.IsAny())) - .Returns(async () => - { - lock (operationLog) - { - operationLog.Add("cpu_wait"); - } - - semaphoreAcquiredSignal.TrySetResult(true); - await cpuCheckSignal.Task; - }); - - var executor = new CliExecutor( - _mockLogger.Object, - _mockCliServices.Object, - _mockSettingsProvider.Object, - _lazyTelemetryManager, - mockThrottler.Object, - cliCommandConcurrencyLimit: 1); - - _mockCommandProvider.Setup(x => x.ReviewFileContentCommand).Returns("review"); - _mockCommandProvider.Setup(x => x.GetReviewFileContentPayload(It.IsAny(), It.IsAny(), TestCachePath)) - .Returns("payload"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())) - .ReturnsAsync(JsonConvert.SerializeObject(new CliReviewModel { Score = 1 })); - - var task1 = executor.ReviewContentAsync("/file1.cs", "content1"); - var task2 = executor.ReviewContentAsync("/file2.cs", "content2"); - - await semaphoreAcquiredSignal.Task; - await Task.Delay(50); - - int cpuWaitCount; - lock (operationLog) - { - cpuWaitCount = operationLog.Count(x => x == "cpu_wait"); - } - - Assert.AreEqual( - 1, - cpuWaitCount, - "Only the operation that acquired the semaphore should check CPU; the other should be blocked waiting for the semaphore"); - - cpuCheckSignal.SetResult(true); - await Task.WhenAll(task1, task2); - } - - [TestMethod] - public async Task ReviewDeltaAsync_AcquiresSemaphoreBeforeWaitingForCpu() - { - var operationLog = new List(); - var semaphoreAcquiredSignal = new TaskCompletionSource(); - var cpuCheckSignal = new TaskCompletionSource(); - - var mockThrottler = new Mock(); - mockThrottler.Setup(x => x.WaitForCpuAsync(It.IsAny())) - .Returns(async () => - { - lock (operationLog) - { - operationLog.Add("cpu_wait"); - } - - semaphoreAcquiredSignal.TrySetResult(true); - await cpuCheckSignal.Task; - }); - - var executor = new CliExecutor( - _mockLogger.Object, - _mockCliServices.Object, - _mockSettingsProvider.Object, - _lazyTelemetryManager, - mockThrottler.Object, - cliCommandConcurrencyLimit: 1); - - var expectedDelta = new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m }; - var jsonResponse = JsonConvert.SerializeObject(expectedDelta); - _mockCommandProvider.Setup(x => x.GetReviewDeltaCommand(It.IsAny(), It.IsAny())) - .Returns("delta command"); - _mockProcessExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(jsonResponse); - - var task1 = executor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old1", NewScore = "new1", FilePath = "/file1.cs" }); - var task2 = executor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old2", NewScore = "new2", FilePath = "/file2.cs" }); - - await semaphoreAcquiredSignal.Task; - await Task.Delay(50); - - int cpuWaitCount; - lock (operationLog) - { - cpuWaitCount = operationLog.Count(x => x == "cpu_wait"); - } - - Assert.AreEqual( - 1, - cpuWaitCount, - "Only the operation that acquired the delta semaphore should check CPU; the other should be blocked waiting for the semaphore"); - - cpuCheckSignal.SetResult(true); - await Task.WhenAll(task1, task2); - } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliObjectScoreCreatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliObjectScoreCreatorTests.cs deleted file mode 100644 index 9508318d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliObjectScoreCreatorTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class CliObjectScoreCreatorTests - { - private Mock _mockLogger; - private CliObjectScoreCreator _creator; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - _creator = new CliObjectScoreCreator(_mockLogger.Object); - } - - [TestMethod] - public void Create_BothNull_ReturnsEmpty() - { - var result = _creator.Create(null, null); - Assert.AreEqual(string.Empty, result); - } - - [TestMethod] - public void Create_BothWhitespace_ReturnsEmpty() - { - var result = _creator.Create(" ", " "); - Assert.AreEqual(string.Empty, result); - } - - [TestMethod] - public void Create_SameScores_ReturnsEmptyAndLogsDebug() - { - var result = _creator.Create("abc", "abc"); - Assert.AreEqual(string.Empty, result); - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("same"))), Times.Once); - } - - [TestMethod] - public void Create_OnlyNewScore_ReturnsJsonWithNewScoreOnly() - { - var result = _creator.Create(null, "newScore"); - Assert.AreEqual("{\"new-score\":\"newScore\"}", result); - } - - [TestMethod] - public void Create_OnlyOldScore_ReturnsJsonWithOldScoreOnly() - { - var result = _creator.Create("oldScore", null); - Assert.AreEqual("{\"old-score\":\"oldScore\"}", result); - } - - [TestMethod] - public void Create_BothScores_ReturnsJsonWithBoth() - { - var result = _creator.Create("old", "new"); - Assert.AreEqual("{\"old-score\":\"old\",\"new-score\":\"new\"}", result); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs index 9ddabcce..b3a0ccd5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs @@ -1,11 +1,14 @@ // Copyright (c) CodeScene. All rights reserved. using Codescene.VSExtension.Core.Application.Services; +using Codescene.VSExtension.Core.Consts; +using Codescene.VSExtension.Core.Models.Cli.Rpc; using Codescene.VSExtension.Core.Util; namespace Codescene.VSExtension.Core.Tests { [TestClass] + [DoNotParallelize] public class CodeHealthMonitorNotifierTests { private CodeHealthMonitorNotifier _notifier; @@ -13,6 +16,7 @@ public class CodeHealthMonitorNotifierTests [TestInitialize] public void Setup() { + DeltaJobTracker.Clear(); _notifier = new CodeHealthMonitorNotifier(); } @@ -88,5 +92,164 @@ public void RequestViewUpdate_WithNoSubscribers_DoesNotThrow() { _notifier.RequestViewUpdate(); } + + [TestMethod] + public void ApplyQueue_MapsRunningAndQueuedJobs() + { + _notifier.ApplyQueue(new ReviewQueue + { + Count = 2, + Done = 1, + Files = new[] { "C:\\repo\\a.cs", "C:\\repo\\b.cs" }, + }); + + var jobs = DeltaJobTracker.RunningJobs.ToList(); + Assert.HasCount(2, jobs); + Assert.AreEqual(WebComponentConstants.StateTypes.RUNNING, jobs[0].State); + Assert.AreEqual(WebComponentConstants.StateTypes.QUEUED, jobs[1].State); + Assert.AreEqual(3, _notifier.TotalCount); + Assert.AreEqual(2, _notifier.RemainingCount); + Assert.AreEqual("running", _notifier.AnalysisState); + } + + [TestMethod] + public void ApplyQueue_ClearsTotalWhenNothingRemains() + { + _notifier.ApplyQueue(new ReviewQueue + { + Count = 2, + Done = 1, + Files = new[] { "a.cs" }, + }); + + _notifier.ApplyQueue(new ReviewQueue + { + Count = 0, + Done = 3, + Files = Array.Empty(), + }); + + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + Assert.IsNull(_notifier.TotalCount); + Assert.AreEqual(0, _notifier.RemainingCount); + Assert.AreEqual("idle", _notifier.AnalysisState); + } + + [TestMethod] + public void ApplyQueue_RequeueDuringBatch_KeepsTotalAtQueueSize() + { + ApplyCounts(19, 1); + Assert.AreEqual(20, _notifier.TotalCount); + + ApplyCounts(23, 17); + Assert.AreEqual(23, _notifier.TotalCount); + + ApplyCounts(23, 37); + Assert.AreEqual(23, _notifier.TotalCount); + Assert.AreEqual(23, _notifier.RemainingCount); + } + + [TestMethod] + public void ApplyQueue_QueueShrinksDuringBatch_LowersTotal() + { + ApplyCounts(448, 1); + Assert.AreEqual(449, _notifier.TotalCount); + + ApplyCounts(27, 10); + Assert.AreEqual(37, _notifier.TotalCount); + } + + [TestMethod] + public void ApplyQueue_NewBatchAfterDrain_StartsFromDoneAndRemaining() + { + ApplyCounts(19, 1); + ApplyCounts(0, 20); + + ApplyCounts(3, 1); + + Assert.AreEqual(4, _notifier.TotalCount); + } + + [TestMethod] + public void SetAutomaticAnalysisEnabled_WhenDisabled_StopsAndClearsJobs() + { + _notifier.ApplyQueue(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); + _notifier.SetAutomaticAnalysisEnabled(false); + + Assert.AreEqual("stopped", _notifier.AnalysisState); + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + } + + [TestMethod] + public void SetAutomaticAnalysisEnabled_WhenReenabledFromStopped_ReturnsToIdle() + { + _notifier.SetAutomaticAnalysisEnabled(false); + var eventFired = false; + _notifier.ViewUpdateRequested += (_, __) => eventFired = true; + + _notifier.SetAutomaticAnalysisEnabled(true); + + Assert.AreEqual("idle", _notifier.AnalysisState); + Assert.IsTrue(eventFired); + } + + [TestMethod] + public void SetAutomaticAnalysisEnabled_NoOpWhenValueUnchanged() + { + _notifier.SetAutomaticAnalysisEnabled(true); + _notifier.ApplyQueue(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); + + _notifier.SetAutomaticAnalysisEnabled(true); + + Assert.AreEqual("running", _notifier.AnalysisState); + Assert.HasCount(1, DeltaJobTracker.RunningJobs); + } + + [TestMethod] + public void OnDeltaStarting_WhenAutomaticAnalysisDisabled_DoesNotTrackJob() + { + _notifier.SetAutomaticAnalysisEnabled(false); + _notifier.OnDeltaStarting("file.cs"); + + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + } + + [TestMethod] + public void ApplyQueue_WhenAutomaticAnalysisDisabled_Stops() + { + _notifier.SetAutomaticAnalysisEnabled(false); + + _notifier.ApplyQueue(new ReviewQueue { Count = 2, Files = new[] { "a.cs", "b.cs" } }); + + Assert.AreEqual("stopped", _notifier.AnalysisState); + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + } + + [TestMethod] + public void Clear_RemovesJobsAndRequestsViewUpdate() + { + var updates = 0; + _notifier.ViewUpdateRequested += (sender, args) => updates++; + _notifier.ApplyQueue(new ReviewQueue + { + Count = 1, + Files = new[] { "C:\\repo\\a.cs" }, + }); + + _notifier.Clear(); + + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + Assert.IsGreaterThan(0, updates); + } + + private void ApplyCounts(int count, int done) + { + _notifier.ApplyQueue(new ReviewQueue + { + Count = count, + Done = done, + Files = count > 0 ? new[] { "a.cs" } : Array.Empty(), + }); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs new file mode 100644 index 00000000..fe35a5ed --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class CodeReviewerPipelineRestoreTests + { + private Mock _mockLogger; + private Mock _mockMapper; + private Mock _mockExecutor; + + [TestInitialize] + public void Setup() + { + _mockLogger = new Mock(); + _mockMapper = new Mock(); + _mockExecutor = new Mock(); + } + + [TestMethod] + public async Task ReviewWithDeltaAsync_WhenContentMatchesDisk_RestoresBufferOwnedMonitor() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-restore-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "content"); + var cliReview = new CliReviewModel { RawScore = "raw" }; + var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((cliReview, (DeltaResponseModel)null)); + _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); + var reviewer = new CodeReviewer( + _mockLogger.Object, + _mockMapper.Object, + _mockExecutor.Object, + pipeline.Object); + + try + { + await reviewer.ReviewWithDeltaAsync(path, "content"); + + pipeline.Verify( + x => x.RestoreFromDiskIfBufferOwned( + It.Is(root => root.Equals(repoRoot, StringComparison.OrdinalIgnoreCase)), + It.Is(document => document.FilePath == path)), + Times.Once); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + + [TestMethod] + public async Task ReviewWithDeltaAsync_WhenContentDiffersFromDisk_DoesNotRestoreBufferOwnedMonitor() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-dirty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "on disk"); + var cliReview = new CliReviewModel { RawScore = "raw" }; + var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((cliReview, (DeltaResponseModel)null)); + _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); + var reviewer = new CodeReviewer( + _mockLogger.Object, + _mockMapper.Object, + _mockExecutor.Object, + pipeline.Object); + + try + { + await reviewer.ReviewWithDeltaAsync(path, "changed"); + + pipeline.Verify( + x => x.RestoreFromDiskIfBufferOwned(It.IsAny(), It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs index 3e958b96..19e0f575 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs @@ -4,12 +4,12 @@ using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Ace; using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; +using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; using Moq; namespace Codescene.VSExtension.Core.Tests @@ -20,9 +20,6 @@ public class CodeReviewerTests private Mock _mockLogger; private Mock _mockMapper; private Mock _mockExecutor; - private Mock _mockTelemetryManager; - private Mock _mockGitService; - private CodeReviewer _codeReviewer; [TestInitialize] public void Setup() @@ -30,555 +27,208 @@ public void Setup() _mockLogger = new Mock(); _mockMapper = new Mock(); _mockExecutor = new Mock(); - _mockTelemetryManager = new Mock(); - _mockGitService = new Mock(); - - _codeReviewer = new CodeReviewer( - _mockLogger.Object, - _mockMapper.Object, - _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object); - } - - [TestMethod] - public async Task ReviewAsync_NullPath_ReturnsNull() - { - // Arrange - string? path = null; - var content = "some code"; - - // Act - var result = await _codeReviewer.ReviewAsync(path, content); - - // Assert - Assert.IsNull(result); - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("Skipping review"))), Times.Once); - } - - [TestMethod] - public async Task ReviewAsync_EmptyPath_ReturnsNull() - { - // Arrange - var path = string.Empty; - var content = "some code"; - - // Act - var result = await _codeReviewer.ReviewAsync(path, content); - - // Assert - Assert.IsNull(result); - } - - [TestMethod] - public async Task ReviewAsync_WhitespacePath_ReturnsNull() - { - // Arrange - var path = " "; - var content = "some code"; - - // Act - var result = await _codeReviewer.ReviewAsync(path, content); - - // Assert - Assert.IsNull(result); - } - - [TestMethod] - public async Task ReviewAsync_NullContent_ReturnsNull() - { - // Arrange - var path = "test.cs"; - string? content = null; - - // Act - var result = await _codeReviewer.ReviewAsync(path, content); - - // Assert - Assert.IsNull(result); - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("Skipping review"))), Times.Once); } [TestMethod] - public async Task ReviewAsync_EmptyContent_ReturnsNull() + public async Task ReviewWithDeltaAsync_WithPipelineOutsideRepository_ReturnsNull() { - var path = "test.cs"; - var content = string.Empty; + var pipeline = new Mock(); + var reviewer = new CodeReviewer( + _mockLogger.Object, + _mockMapper.Object, + _mockExecutor.Object, + pipeline.Object); - var result = await _codeReviewer.ReviewAsync(path, content); + var result = await reviewer.ReviewWithDeltaAsync( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "test.cs"), + "content"); - Assert.IsNull(result); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); + Assert.IsNull(result.review); + Assert.IsNull(result.delta); + pipeline.Verify( + x => x.SubmitAsync(It.IsAny(), It.IsAny()), + Times.Never); } [TestMethod] - public async Task ReviewAsync_WhitespaceContent_ReturnsNull() + public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() { - var path = "test.cs"; - var content = " "; - - var result = await _codeReviewer.ReviewAsync(path, content); + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "content"); + var cliReview = new CliReviewModel { RawScore = "raw" }; + var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; + var delta = new DeltaResponseModel { ScoreChange = 1m }; + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((cliReview, delta)); + _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); + var reviewer = new CodeReviewer( + _mockLogger.Object, + _mockMapper.Object, + _mockExecutor.Object, + pipeline.Object); - Assert.IsNull(result); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); + try + { + var result = await reviewer.ReviewWithDeltaAsync(path, "changed"); + + Assert.IsNotNull(result.review); + Assert.AreEqual(path, result.review.FilePath); + Assert.AreSame(delta, result.delta); + _mockMapper.Verify(x => x.Map(path, cliReview), Times.Once); + pipeline.Verify( + x => x.SubmitAsync( + It.Is(root => root.Equals(repoRoot, StringComparison.OrdinalIgnoreCase)), + It.Is(submission => + submission.Document.FilePath == path && + submission.Document.IsDirty && + submission.RelPath == "test.cs" && + submission.Content == "changed" && + submission.UpdateDiagnosticsPane && + submission.UpdateMonitor)), + Times.Once); + } + finally + { + Directory.Delete(repoRoot, true); + } } [TestMethod] - public async Task ReviewAsync_ValidInput_CallsExecutorAndMapper() + public async Task ReviewWithDeltaAsync_WhenPipelineReturnsNull_ReturnsNull() { - // Arrange - var path = "C:/project/test.cs"; - var content = "public class Test { }"; - var cliReview = new CliReviewModel { Score = 8.5f, RawScore = "raw123" }; - var expectedResult = new FileReviewModel { FilePath = path, Score = 8.5f }; - - _mockExecutor.Setup(x => x.ReviewContentAsync(path, content, It.IsAny(), It.IsAny())).ReturnsAsync(cliReview); - _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(expectedResult); + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "same"); + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(((CliReviewModel)null, (DeltaResponseModel)null)); + var reviewer = new CodeReviewer(_mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, pipeline.Object); - // Act - var result = await _codeReviewer.ReviewAsync(path, content); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(path, result.FilePath); - Assert.AreEqual(8.5f, result.Score); - _mockExecutor.Verify(x => x.ReviewContentAsync(path, content, It.IsAny(), It.IsAny()), Times.Once); - _mockMapper.Verify(x => x.Map(path, cliReview), Times.Once); + try + { + var result = await reviewer.ReviewWithDeltaAsync(path, "same"); + Assert.IsNull(result.review); + Assert.IsNull(result.delta); + } + finally + { + Directory.Delete(repoRoot, true); + } } [TestMethod] - public async Task ReviewAsync_PassesFullPathToExecutor() + public async Task ReviewWithDeltaAsync_WhenRawScoreMissing_ReturnsReviewWithoutDelta() { - // Arrange - var path = "C:/some/deep/path/to/MyFile.cs"; - var content = "code"; + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "content"); var cliReview = new CliReviewModel(); - - _mockExecutor.Setup(x => x.ReviewContentAsync(path, content, It.IsAny(), It.IsAny())).ReturnsAsync(cliReview); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())).Returns(new FileReviewModel()); - - // Act - await _codeReviewer.ReviewAsync(path, content); - - // Assert - _mockExecutor.Verify(x => x.ReviewContentAsync(path, content, It.IsAny(), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_NullFilePath_ReturnsNull() - { - // Arrange - var review = new FileReviewModel { FilePath = null, RawScore = "raw" }; - - // Act - var result = await _codeReviewer.DeltaAsync(review, "current code"); - - // Assert - Assert.IsNull(result); - _mockLogger.Verify(l => l.Warn(It.Is(s => s.Contains("missing file path"))), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_EmptyFilePath_ReturnsNull() - { - // Arrange - var review = new FileReviewModel { FilePath = string.Empty, RawScore = "raw" }; - - // Act - var result = await _codeReviewer.DeltaAsync(review, "current code"); - - // Assert - Assert.IsNull(result); - _mockLogger.Verify(l => l.Warn(It.Is(s => s.Contains("missing file path"))), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_WhitespaceFilePath_ReturnsNull() - { - // Arrange - var review = new FileReviewModel { FilePath = " ", RawScore = "raw" }; - - // Act - var result = await _codeReviewer.DeltaAsync(review, "current code"); - - // Assert - Assert.IsNull(result); - } - - [TestMethod] - public async Task DeltaAsync_WhenExceptionThrown_LogsErrorAndReturnsNull() - { - var review = new FileReviewModel { FilePath = "test.cs", RawScore = "raw" }; - var expectedException = new Exception("Git error"); - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())) - .Throws(expectedException); - - var result = await _codeReviewer.DeltaAsync(review, "current code"); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Error(It.Is(s => s.Contains("test.cs")), expectedException), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_WhenReviewDeltaAsyncThrows_LogsErrorAndReturnsNull() - { - var review = new FileReviewModel { FilePath = "test.cs", RawScore = "raw" }; - var expectedException = new InvalidOperationException("delta failed"); - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())).Returns("old"); - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = "old-raw" }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = "old-raw" }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(expectedException); - - var result = await _codeReviewer.DeltaAsync(review, "current code"); - - Assert.IsNull(result); - _mockLogger.Verify(l => l.Error(It.Is(s => s.Contains("delta analysis")), expectedException), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_NullRawScore_UsesEmptyString() - { - // Arrange - var review = new FileReviewModel { FilePath = "test.cs", RawScore = null }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())) - .Returns("old code"); - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = "old-raw" }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = "old-raw" }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new DeltaResponseModel()); - - // Act - should not throw - await _codeReviewer.DeltaAsync(review, "current code"); - - // Assert - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.Is(r => r.NewScore == string.Empty), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_IdenticalContent_StillCallsDeltaAnalysis() - { - var currentCode = "public class Test { }"; - var review = new FileReviewModel { FilePath = "test.cs", RawScore = "raw-score" }; - var expectedDelta = new DeltaResponseModel { ScoreChange = 0m }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())) - .Returns(currentCode); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - var result = await _codeReviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_IdenticalScores_StillCallsDeltaAnalysis() - { - var oldCode = "public class Test { int x = 1; }"; - var currentCode = "public class Test { int y = 1; }"; - var identicalRawScore = "identical-raw-score"; - var review = new FileReviewModel { FilePath = "test.cs", RawScore = identicalRawScore }; - var expectedDelta = new DeltaResponseModel { ScoreChange = 0m }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())) - .Returns(oldCode); - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), oldCode, It.IsAny(), It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = identicalRawScore }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = identicalRawScore }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedDelta); - - var result = await _codeReviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeltaAsync_DifferentContent_CallsReviewDelta() - { - // Arrange - var oldCode = "public class Test { }"; - var currentCode = "public class Test { int x; }"; - var review = new FileReviewModel { FilePath = "test.cs", RawScore = "new-raw" }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(It.IsAny(), It.IsAny())) - .Returns(oldCode); - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), oldCode, It.IsAny(), It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = "old-raw" }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = "old-raw" }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new DeltaResponseModel { ScoreChange = -0.5m }); - - // Act - var result = await _codeReviewer.DeltaAsync(review, currentCode); - - // Assert - Assert.IsNotNull(result); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.Is(r => r.OldScore == "old-raw" && r.NewScore == "new-raw" && r.FilePath == "test.cs" && r.FileContent == currentCode), It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task ReviewAndBaselineAsync_ReturnsReviewAndBaselineRawScore() - { - var path = "test.cs"; - var currentCode = "public class Test { }"; - var oldCode = "public class OldTest { }"; - var review = new FileReviewModel { FilePath = path, RawScore = "current-raw", Score = 8.5f }; - var cliReview = new CliReviewModel { RawScore = "current-raw" }; - var baselineCliReview = new CliReviewModel { RawScore = "baseline-raw" }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", currentCode, false, It.IsAny())).ReturnsAsync(cliReview); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", oldCode, true, It.IsAny())).ReturnsAsync(baselineCliReview); - _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); - _mockMapper.Setup(x => x.Map(path, baselineCliReview)).Returns(new FileReviewModel { FilePath = path, RawScore = "baseline-raw", Score = 7.0f }); - - var (actualReview, actualBaseline) = await _codeReviewer.ReviewAndBaselineAsync(path, currentCode); - - Assert.IsNotNull(actualReview); - Assert.AreEqual("current-raw", actualReview.RawScore); - Assert.AreEqual("baseline-raw", actualBaseline); - } - - [TestMethod] - public async Task ReviewAndBaselineAsync_WhenGitReturnsNull_UsesEmptyStringForOldCode() - { - var path = "test.cs"; - var currentCode = "public class Test { }"; - var cliReview = new CliReviewModel { RawScore = "current-raw" }; - var review = new FileReviewModel { FilePath = path, RawScore = "current-raw" }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns((string)null); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", currentCode, false, It.IsAny())).ReturnsAsync(cliReview); + var review = new FileReviewModel { FilePath = path }; + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((cliReview, new DeltaResponseModel())); _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); + var reviewer = new CodeReviewer(_mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, pipeline.Object); - var (actualReview, actualBaseline) = await _codeReviewer.ReviewAndBaselineAsync(path, currentCode); - - Assert.IsNotNull(actualReview); - Assert.AreEqual(string.Empty, actualBaseline); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GetOrComputeBaselineRawScoreAsync_WhenBaselineContentEmptyAndNoCommit_ReturnsEmptyWithoutCallingExecutor() - { - var path = "test.cs"; - var baselineContent = string.Empty; - - var result = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - - Assert.AreEqual(string.Empty, result); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GetOrComputeBaselineRawScoreAsync_WhenBaselineContentEmptyButCommitProvided_RetrievesFromGit() - { - var path = "test.cs"; - var baselineContent = string.Empty; - var baselineCommit = "abc123"; - var gitContent = "public class FromGit { }"; - var expectedRawScore = "git-raw-score"; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, baselineCommit)).Returns(gitContent); - _mockExecutor.Setup(x => x.ReviewContentAsync(path, gitContent, true, It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = expectedRawScore }); - _mockMapper.Setup(x => x.Map(path, It.IsAny())) - .Returns(new FileReviewModel { RawScore = expectedRawScore }); - - var result = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent, baselineCommit: baselineCommit); - - Assert.AreEqual(expectedRawScore, result); - _mockGitService.Verify(x => x.GetFileContentForCommit(path, baselineCommit), Times.Once); - _mockExecutor.Verify(x => x.ReviewContentAsync(path, gitContent, true, It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task GetOrComputeBaselineRawScoreAsync_WhenCached_ReturnsCachedScore() - { - var path = "test.cs"; - var baselineContent = "cached content"; - - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), baselineContent, true, It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = "cached-raw" }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = "cached-raw" }); - - var result = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - var secondResult = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - - Assert.AreEqual("cached-raw", result); - Assert.AreEqual("cached-raw", secondResult); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), baselineContent, true, It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task GetOrComputeBaselineRawScoreAsync_WhenReviewReturnsNullRawScore_DoesNotCache() - { - var path = "uncached.cs"; - var baselineContent = "content without raw score"; - - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), baselineContent, true, It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = null }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = null }); - - var result = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - - var secondResult = await _codeReviewer.GetOrComputeBaselineRawScoreAsync(path, baselineContent); - - Assert.AreEqual(string.Empty, result); - Assert.AreEqual(string.Empty, secondResult); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), baselineContent, true, It.IsAny()), Times.Exactly(2)); - } - - [TestMethod] - public async Task ReviewWithDeltaAsync_WhenReviewHasNullRawScore_ReturnsNullDelta() - { - var path = "test.cs"; - var content = "public class Test { }"; - var review = new FileReviewModel { FilePath = path, RawScore = null }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns("old code"); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", content, false, It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = null }); - _mockMapper.Setup(x => x.Map(path, It.IsAny())).Returns(review); - - var (actualReview, delta) = await _codeReviewer.ReviewWithDeltaAsync(path, content); - - Assert.IsNotNull(actualReview); - Assert.IsNull(delta); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task DeltaAsync_WithPreflightManager_CallsPreflightAndRefactorableFunctions() - { - var mockPreflight = new Mock(); - var preflightResponse = new PreFlightResponseModel(); - var path = "test.cs"; - var currentCode = "public class Test { void Foo() {} }"; - var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; - var delta = new DeltaResponseModel + try { - ScoreChange = -1m, - FunctionLevelFindings = new[] - { - new FunctionFindingModel { Function = new FunctionInfoModel { Name = "Foo" } }, - }, - }; - var refactorable = new FnToRefactorModel { Name = "Foo", Body = "void Foo() {}" }; - - var reviewer = new CodeReviewer( - _mockLogger.Object, - _mockMapper.Object, - _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - null, - mockPreflight.Object); - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns("old"); - _mockExecutor.Setup(x => x.ReviewContentAsync(It.IsAny(), "old", true, It.IsAny())) - .ReturnsAsync(new CliReviewModel { RawScore = "old-raw" }); - _mockMapper.Setup(x => x.Map(It.IsAny(), It.IsAny())) - .Returns(new FileReviewModel { RawScore = "old-raw" }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(delta); - mockPreflight.Setup(x => x.GetPreflightResponseAsync(It.IsAny())) - .ReturnsAsync(preflightResponse); - _mockExecutor.Setup(x => x.FnsToRefactorFromDeltaAsync(path, currentCode, delta, preflightResponse, It.IsAny())) - .ReturnsAsync(new List { refactorable }); - - var result = await reviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - Assert.AreEqual("Foo", result.FunctionLevelFindings[0].RefactorableFn.Name); - mockPreflight.Verify(x => x.GetPreflightResponseAsync(It.IsAny()), Times.Once); + var result = await reviewer.ReviewWithDeltaAsync(path, "changed"); + Assert.AreSame(review, result.review); + Assert.IsNull(result.delta); + } + finally + { + Directory.Delete(repoRoot, true); + } } [TestMethod] - public async Task DeltaAsync_WithPreflightManager_NullRefactorables_ReturnsDeltaUnchanged() + public async Task ReviewWithDeltaAsync_WhenAutomaticAnalysisDisabled_SkipsMonitorUpdate() { - var mockPreflight = new Mock(); - var path = "test.cs"; - var currentCode = "code"; - var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; - var delta = new DeltaResponseModel { ScoreChange = -1m }; - + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "disk"); + var settings = new Mock(); + settings.Setup(x => x.EnableAutomaticAnalysis).Returns(false); + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((new CliReviewModel { RawScore = "raw" }, (DeltaResponseModel)null)); + _mockMapper.Setup(x => x.Map(path, It.IsAny())).Returns(new FileReviewModel()); var reviewer = new CodeReviewer( _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - null, - mockPreflight.Object); - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns("old"); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(delta); - mockPreflight.Setup(x => x.GetPreflightResponseAsync(It.IsAny())) - .ReturnsAsync(new PreFlightResponseModel()); - _mockExecutor.Setup(x => x.FnsToRefactorFromDeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((IList)null); + pipeline.Object, + settings: settings.Object); - var result = await reviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - Assert.AreEqual(-1m, result.ScoreChange); - _mockLogger.Verify(l => l.Error(It.Is(s => s.Contains("delta analysis")), It.IsAny()), Times.Never); + try + { + await reviewer.ReviewWithDeltaAsync(path, "buffer"); + pipeline.Verify( + x => x.SubmitAsync( + It.IsAny(), + It.Is(submission => !submission.UpdateMonitor)), + Times.Once); + } + finally + { + Directory.Delete(repoRoot, true); + } } [TestMethod] - public async Task DeltaAsync_WithPreflightManager_NonMatchingRefactorables_DoesNotAssign() + public async Task ReviewWithDeltaAsync_EnrichesDeltaWithRefactorableFunctions() { - var mockPreflight = new Mock(); - var path = "test.cs"; - var currentCode = "code"; - var review = new FileReviewModel { FilePath = path, RawScore = "raw" }; + var repoRoot = Path.Combine(Path.GetTempPath(), "code-reviewer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + LibGit2Sharp.Repository.Init(repoRoot); + var path = Path.Combine(repoRoot, "test.cs"); + File.WriteAllText(path, "content"); var delta = new DeltaResponseModel { - ScoreChange = -1m, FunctionLevelFindings = new[] { - new FunctionFindingModel { Function = new FunctionInfoModel { Name = "Bar" } }, + new FunctionFindingModel { Function = new FunctionInfoModel { Name = "Fn" } }, }, }; - + var pipeline = new Mock(); + pipeline.Setup(x => x.SubmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((new CliReviewModel { RawScore = "raw" }, delta)); + _mockMapper.Setup(x => x.Map(path, It.IsAny())).Returns(new FileReviewModel { RawScore = "raw" }); + var preflight = new Mock(); + preflight.Setup(x => x.GetPreflightResponseAsync(It.IsAny())) + .ReturnsAsync(new PreFlightResponseModel()); + _mockExecutor.Setup(x => x.FnsToRefactorFromDeltaAsync(path, "changed", delta, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new FnToRefactorModel { Name = "Fn" } }); var reviewer = new CodeReviewer( _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - null, - mockPreflight.Object); + pipeline.Object, + preflight.Object); - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns("old"); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(delta); - mockPreflight.Setup(x => x.GetPreflightResponseAsync(It.IsAny())) - .ReturnsAsync(new PreFlightResponseModel()); - _mockExecutor.Setup(x => x.FnsToRefactorFromDeltaAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { new FnToRefactorModel { Name = "Unrelated" } }); - - var result = await reviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - Assert.IsNull(result.FunctionLevelFindings[0].RefactorableFn); + try + { + var result = await reviewer.ReviewWithDeltaAsync(path, "changed"); + Assert.IsNotNull(result.delta?.FunctionLevelFindings[0].RefactorableFn); + Assert.AreEqual("Fn", result.delta.FunctionLevelFindings[0].RefactorableFn.Name); + } + finally + { + Directory.Delete(repoRoot, true); + } } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/Codescene.VSExtension.Core.Tests.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/Codescene.VSExtension.Core.Tests.csproj index 721bfbe8..e11cc8b0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/Codescene.VSExtension.Core.Tests.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/Codescene.VSExtension.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net48 @@ -6,13 +6,14 @@ enable enable true + $(NoWarn);VSTHRD002;VSTHRD003;VSTHRD103;VSTHRD110;VSTHRD200;CS8602;CS8604 - - - - + + + + all runtime; build; native; contentfiles; analyzers diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneFileWatcherTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneFileWatcherTests.cs index dd8cf83f..752c330a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneFileWatcherTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneFileWatcherTests.cs @@ -128,7 +128,7 @@ public void Constructor_WhenWatcherCreationFails_LogsError() } Assert.IsTrue( - _logger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Could not create watcher")), + _logger.SnapshotErrorMessages().Any(m => m.Contains("Could not create watcher")), "Should log error when FileSystemWatcher creation fails"); } finally @@ -180,7 +180,7 @@ public async Task FileChanged_ThrowingHandler_LogsErrorAndDoesNotCrash() var deadline = DateTime.UtcNow.AddMilliseconds(3000); while (DateTime.UtcNow < deadline) { - if (_logger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error in FileChanged handler"))) + if (_logger.SnapshotErrorMessages().Any(m => m.Contains("Error in FileChanged handler"))) { break; } @@ -189,7 +189,7 @@ public async Task FileChanged_ThrowingHandler_LogsErrorAndDoesNotCrash() } Assert.IsTrue( - _logger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error in FileChanged handler")), + _logger.SnapshotErrorMessages().Any(m => m.Contains("Error in FileChanged handler")), "Error should be logged when handler throws"); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneRepoConfigTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneRepoConfigTests.cs deleted file mode 100644 index 5f595716..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneRepoConfigTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class CodesceneRepoConfigTests - { - private string _gitRootPath; - private string _configPath; - - [TestInitialize] - public void Setup() - { - _gitRootPath = Path.Combine(Path.GetTempPath(), $"codescene-config-{Guid.NewGuid()}"); - Directory.CreateDirectory(_gitRootPath); - var codesceneDir = Path.Combine(_gitRootPath, CodesceneFileWatcher.CodesceneDir); - Directory.CreateDirectory(codesceneDir); - _configPath = Path.Combine(codesceneDir, CodesceneFileWatcher.ConfigFileName); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_gitRootPath)) - { - try - { - Directory.Delete(_gitRootPath, true); - } - catch - { - } - } - } - - [TestMethod] - public void GetBaselineBranch_WhenFileMissing_ReturnsNull() - { - var result = CodesceneRepoConfig.GetBaselineBranch(_gitRootPath); - - Assert.IsNull(result); - } - - [TestMethod] - public void GetBaselineBranch_WhenValid_ReturnsBranchName() - { - File.WriteAllText(_configPath, "{\"baseline_branch\":\"develop\"}"); - - var result = CodesceneRepoConfig.GetBaselineBranch(_gitRootPath); - - Assert.AreEqual("develop", result); - } - - [TestMethod] - public void GetBaselineBranch_WhenWhitespaceOnly_ReturnsNull() - { - File.WriteAllText(_configPath, "{\"baseline_branch\":\" \"}"); - - var result = CodesceneRepoConfig.GetBaselineBranch(_gitRootPath); - - Assert.IsNull(result); - } - - [TestMethod] - public void GetBaselineBranch_WhenInvalidJson_ReturnsNull() - { - File.WriteAllText(_configPath, "not json"); - - var result = CodesceneRepoConfig.GetBaselineBranch(_gitRootPath); - - Assert.IsNull(result); - } - - [TestMethod] - public void GetBaselineBranch_WhenPropertyMissing_ReturnsNull() - { - File.WriteAllText(_configPath, "{\"other\":\"value\"}"); - - var result = CodesceneRepoConfig.GetBaselineBranch(_gitRootPath); - - Assert.IsNull(result); - } - - [TestMethod] - public void GetBaselineBranch_WhenGitRootNull_ReturnsNull() - { - var result = CodesceneRepoConfig.GetBaselineBranch(null); - - Assert.IsNull(result); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CoreCountUtilsTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CoreCountUtilsTests.cs deleted file mode 100644 index 5d67c011..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CoreCountUtilsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Util; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class CoreCountUtilsTests - { - [TestMethod] - public void GetParallelizationCountByCoreCount_WithOneCore_ReturnsOne() - => Assert.AreEqual(1, CoreCountUtils.GetParallelizationCountByCoreCount(1)); - - [TestMethod] - public void GetParallelizationCountByCoreCount_WithThreeCores_ReturnsOne() - => Assert.AreEqual(1, CoreCountUtils.GetParallelizationCountByCoreCount(3)); - - [TestMethod] - public void GetParallelizationCountByCoreCount_WithFourCores_ReturnsOne() - => Assert.AreEqual(1, CoreCountUtils.GetParallelizationCountByCoreCount(4)); - - [TestMethod] - public void GetParallelizationCountByCoreCount_WithSevenCores_ReturnsTwo() - => Assert.AreEqual(2, CoreCountUtils.GetParallelizationCountByCoreCount(7)); - - [TestMethod] - public void GetParallelizationCountByCoreCount_WithTwelveCores_ReturnsThree() - => Assert.AreEqual(3, CoreCountUtils.GetParallelizationCountByCoreCount(12)); - - [TestMethod] - public void GetParallelizationCountByCoreCount_WithZeroCores_ReturnsOne() - => Assert.AreEqual(1, CoreCountUtils.GetParallelizationCountByCoreCount(0)); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CpuUsageCheckerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CpuUsageCheckerTests.cs deleted file mode 100644 index 3175085b..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CpuUsageCheckerTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Util; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class CpuUsageCheckerTests - { - private Mock _mockLogger; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - } - - [TestMethod] - [DataRow(0, 75)] - [DataRow(1, 75)] - [DataRow(2, 75)] - [DataRow(3, 75)] - [DataRow(4, 80)] - [DataRow(5, 80)] - [DataRow(6, 80)] - [DataRow(7, 80)] - [DataRow(8, 85)] - [DataRow(9, 85)] - [DataRow(16, 85)] - public void GetThresholdForCoreCount_ReturnsCorrectThreshold_WithTenPercentHigherThanCpuMonitor(int coreCount, int expectedThreshold) - { - var result = CpuUsageChecker.GetThresholdForCoreCount(coreCount); - Assert.AreEqual(expectedThreshold, result); - } - - [TestMethod] - public void GetThresholdForCoreCount_Is10PercentHigherThanCpuMonitor() - { - Assert.AreEqual(CpuMonitor.GetThresholdForCoreCount(2) + 10, CpuUsageChecker.GetThresholdForCoreCount(2)); - Assert.AreEqual(CpuMonitor.GetThresholdForCoreCount(6) + 10, CpuUsageChecker.GetThresholdForCoreCount(6)); - Assert.AreEqual(CpuMonitor.GetThresholdForCoreCount(8) + 10, CpuUsageChecker.GetThresholdForCoreCount(8)); - } - - [TestMethod] - public async Task IsCpuTooBusyAsync_WhenBelowThreshold_ReturnsFalse() - { - var sampleCallCount = 0; - var checker = new CpuUsageChecker( - _mockLogger.Object, - () => - { - sampleCallCount++; - return Task.FromResult(50.0); - }, - () => 8); - - var result = await checker.IsCpuTooBusyAsync(); - - Assert.IsFalse(result); - Assert.AreEqual(5, sampleCallCount); - } - - [TestMethod] - public async Task IsCpuTooBusyAsync_WhenAboveThreshold_ReturnsTrue() - { - var checker = new CpuUsageChecker( - _mockLogger.Object, - () => Task.FromResult(90.0), - () => 8); - - var result = await checker.IsCpuTooBusyAsync(); - - Assert.IsTrue(result); - } - - [TestMethod] - [DataRow(8, 50, false, DisplayName = "8 cores at 50% usage - below 85% threshold")] - [DataRow(8, 85, false, DisplayName = "8 cores at 85% usage - at 85% threshold")] - [DataRow(8, 90, true, DisplayName = "8 cores at 90% usage - above 85% threshold")] - [DataRow(6, 50, false, DisplayName = "6 cores at 50% usage - below 80% threshold")] - [DataRow(6, 80, false, DisplayName = "6 cores at 80% usage - at 80% threshold")] - [DataRow(6, 85, true, DisplayName = "6 cores at 85% usage - above 80% threshold")] - [DataRow(2, 50, false, DisplayName = "2 cores at 50% usage - below 75% threshold")] - [DataRow(2, 75, false, DisplayName = "2 cores at 75% usage - at 75% threshold")] - [DataRow(2, 80, true, DisplayName = "2 cores at 80% usage - above 75% threshold")] - public async Task IsCpuTooBusyAsync_WithMockedCoreCount_VerifiesHigherThresholdTiers( - int coreCount, int usagePercent, bool expectedTooBusy) - { - var checker = new CpuUsageChecker( - _mockLogger.Object, - () => Task.FromResult((double)usagePercent), - () => coreCount); - - var result = await checker.IsCpuTooBusyAsync(); - - Assert.AreEqual(expectedTooBusy, result); - } - - [TestMethod] - public async Task IsCpuTooBusyAsync_WhenExceptionOccurs_ReturnsFalseAndLogs() - { - var checker = new CpuUsageChecker( - _mockLogger.Object, - () => throw new System.InvalidOperationException("Test exception"), - () => 8); - - var result = await checker.IsCpuTooBusyAsync(); - - Assert.IsFalse(result); - _mockLogger.Verify(x => x.Warn(It.Is(s => s.Contains("CPU check failed"))), Times.Once); - } - - [TestMethod] - public async Task NoOpCpuUsageChecker_AlwaysReturnsFalse() - { - var checker = new NoOpCpuUsageChecker(); - - var result = await checker.IsCpuTooBusyAsync(); - - Assert.IsFalse(result); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs index 898296ed..c9e584f7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs @@ -124,6 +124,102 @@ public async Task Debounce_LogsDebugMessage_WhenActionExecutes() _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("test-key"))), Times.Once); } + [TestMethod] + public async Task Throttle_EmitsLeadingImmediatelyAndTrailingWhileBusy() + { + var emissions = 0; + var second = new TaskCompletionSource(); + var interval = TimeSpan.FromMilliseconds(200); + + _debounceService.Throttle("view", () => emissions++, interval); + Assert.AreEqual(1, emissions); + + var started = DateTime.UtcNow; + while (!second.Task.IsCompleted && (DateTime.UtcNow - started).TotalMilliseconds < 2000) + { + _debounceService.Throttle( + "view", + () => + { + emissions++; + if (emissions >= 2) + { + second.TrySetResult(true); + } + }, + interval); + await Task.Delay(20); + } + + Assert.IsTrue(second.Task.IsCompleted); + } + + [TestMethod] + public void Throttle_LogsErrorWhenLeadingActionThrows() + { + _debounceService.Throttle("view", () => throw new InvalidOperationException("leading"), TimeSpan.FromSeconds(1)); + + _mockLogger.Verify( + l => l.Error("Unable to perform throttled action", It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Throttle_LogsErrorWhenTrailingActionThrows() + { + var interval = TimeSpan.FromMilliseconds(100); + _debounceService.Throttle("view", () => { }, interval); + var trailingStarted = new TaskCompletionSource(); + _debounceService.Throttle( + "view", + () => + { + trailingStarted.TrySetResult(true); + throw new InvalidOperationException("trailing"); + }, + interval); + + await trailingStarted.Task; + await Task.Delay(interval + SafetyBuffer); + + _mockLogger.Verify( + l => l.Error("Unable to perform throttled action", It.IsAny()), + Times.AtLeastOnce); + } + + [TestMethod] + public async Task Cancel_PreventsDebouncedAction() + { + var executed = false; + _debounceService.Debounce("test-key", () => executed = true, TimeSpan.FromMilliseconds(200)); + _debounceService.Cancel("test-key"); + + await WaitForDebounce(TimeSpan.FromMilliseconds(200)); + + Assert.IsFalse(executed); + } + + [TestMethod] + public void Cancel_UnknownKey_DoesNotThrow() + { + _debounceService.Cancel("missing-key"); + } + + [TestMethod] + public async Task Throttle_TimerWithNoTrailingAction_CompletesSafely() + { + _debounceService.Throttle("view", () => { }, TimeSpan.FromMilliseconds(50)); + await Task.Delay(150); + } + + [TestMethod] + public void Cancel_AfterThrottle_ClearsPendingTrailing() + { + _debounceService.Throttle("view", () => { }, TimeSpan.FromSeconds(30)); + _debounceService.Throttle("view", () => { }, TimeSpan.FromSeconds(30)); + _debounceService.Cancel("view"); + } + [TestMethod] public void Dispose_CanBeCalledMultipleTimes() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DefaultBranchGateTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DefaultBranchGateTests.cs deleted file mode 100644 index ff3e0cfd..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DefaultBranchGateTests.cs +++ /dev/null @@ -1,312 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class DefaultBranchGateTests - { - private string _testRepoPath; - - [TestInitialize] - public void Setup() - { - MainBranchNames.ClearDefaultBranchCache(); - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-default-branch-gate-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testRepoPath)) - { - try - { - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - [TestMethod] - public void ShouldSkip_WhenRepoIsNull_ReturnsFalse() - { - var gate = new DefaultBranchGate(_testRepoPath); - - var result = gate.ShouldSkip(null); - - Assert.IsFalse(result); - } - - [TestMethod] - public void ShouldSkip_WhenCurrentBranchIsEmpty_ReturnsFalse() - { - SetupOriginHead("main"); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Checkout(repo, repo.Head.Tip); - } - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsFalse(result, "Should return false in detached HEAD state"); - } - } - - [TestMethod] - public void ShouldSkip_WhenOnMainBranchWithOriginHead_ReturnsTrue() - { - SetupOriginHead("main"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsTrue(result, "Should skip when current branch matches default branch"); - } - } - - [TestMethod] - public void ShouldSkip_WhenOnFeatureBranch_ReturnsFalse() - { - SetupOriginHead("main"); - ExecGit("checkout -b feature"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsFalse(result, "Should not skip when on feature branch"); - } - } - - [TestMethod] - public void ShouldSkip_ComparisonIsCaseInsensitive() - { - SetupOriginHead("MAIN"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsTrue(result, "main should match MAIN case-insensitively"); - } - } - - [TestMethod] - public void ShouldSkip_CachesDefaultBranchAfterFirstFetch() - { - SetupOriginHead("main"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var firstResult = gate.ShouldSkip(repo); - Assert.IsTrue(firstResult); - } - - SetupOriginHead("develop"); - - using (var repo = new Repository(_testRepoPath)) - { - var secondResult = gate.ShouldSkip(repo); - - Assert.IsTrue(secondResult, "Should still use cached 'main' as default branch"); - } - } - - [TestMethod] - public void ShouldSkip_CurrentBranchIsComputedFreshEachCall() - { - SetupOriginHead("main"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - Assert.IsTrue(result, "Should skip when on main"); - } - - ExecGit("checkout -b feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - Assert.IsFalse(result, "Should not skip when on feature branch"); - } - - ExecGit("checkout main"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - Assert.IsTrue(result, "Should skip again when back on main"); - } - } - - [TestMethod] - public void ShouldSkip_WhenNoOriginHead_ReturnsFalse() - { - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsFalse(result, "Should not skip when default branch cannot be determined"); - } - } - - [TestMethod] - public void ShouldSkipForCurrentBranch_WhenOnMainBranch_ReturnsTrue() - { - SetupOriginHead("main"); - - var gate = new DefaultBranchGate(_testRepoPath); - - var result = gate.ShouldSkipForCurrentBranch(); - - Assert.IsTrue(result, "Should skip when current branch matches default branch"); - } - - [TestMethod] - public void ShouldSkipForCurrentBranch_WhenOnFeatureBranch_ReturnsFalse() - { - SetupOriginHead("main"); - ExecGit("checkout -b feature"); - - var gate = new DefaultBranchGate(_testRepoPath); - - var result = gate.ShouldSkipForCurrentBranch(); - - Assert.IsFalse(result, "Should not skip when on feature branch"); - } - - [TestMethod] - public void ShouldSkip_UsesBaselineBranchFromConfig() - { - ExecGit("checkout -b develop"); - WriteConfig("{\"baseline_branch\":\"develop\"}"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsTrue(result, "Should skip when current branch matches configured baseline branch"); - } - } - - [TestMethod] - public void ShouldSkip_ConfigOverridesOriginHead() - { - SetupOriginHead("main"); - ExecGit("checkout -b develop"); - WriteConfig("{\"baseline_branch\":\"develop\"}"); - - var gate = new DefaultBranchGate(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var result = gate.ShouldSkip(repo); - - Assert.IsTrue(result, "Should use configured baseline branch instead of origin/HEAD"); - } - } - - private void SetupOriginHead(string branchName) - { - using (var repo = new Repository(_testRepoPath)) - { - var existingOriginHead = repo.Refs[$"refs/remotes/origin/HEAD"]; - if (existingOriginHead != null) - { - repo.Refs.Remove(existingOriginHead); - } - - var existingRef = repo.Refs[$"refs/remotes/origin/{branchName}"]; - if (existingRef != null) - { - repo.Refs.Remove(existingRef); - } - - repo.Refs.Add($"refs/remotes/origin/{branchName}", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs[$"refs/remotes/origin/{branchName}"]); - } - } - - private void WriteConfig(string json) - { - var codesceneDir = Path.Combine(_testRepoPath, CodesceneFileWatcher.CodesceneDir); - Directory.CreateDirectory(codesceneDir); - File.WriteAllText(Path.Combine(codesceneDir, CodesceneFileWatcher.ConfigFileName), json); - } - - private void CommitFile(string filename, string content, string message) - { - var filePath = Path.Combine(_testRepoPath, filename); - File.WriteAllText(filePath, content); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, filename); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit(message, signature, signature); - } - } - - private void ExecGit(string args) - { - var psi = new ProcessStartInfo - { - FileName = "git", - Arguments = args, - WorkingDirectory = _testRepoPath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using (var process = Process.Start(psi)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"Git command failed: {args}\n{error}"); - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaCacheServiceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaCacheServiceTests.cs index d2b64a8b..c90f09ca 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaCacheServiceTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaCacheServiceTests.cs @@ -325,6 +325,24 @@ public void GetDeltaForFile_FileInCacheWithNullDelta_ReturnsNull() Assert.IsNull(result); } + [TestMethod] + [DataRow(null)] + [DataRow("")] + public void SetPresentationDelta_NullOrEmptyPath_DoesNotStore(string path) + { + _cacheService.SetPresentationDelta(path, CreateDelta(1m)); + + Assert.IsEmpty(_cacheService.GetAll()); + } + + [TestMethod] + public void SetPresentationDelta_ValidPath_StoresDelta() + { + _cacheService.SetPresentationDelta(_tempFile, CreateDelta(2m)); + + Assert.AreEqual(2m, _cacheService.GetDeltaForFile(_tempFile).ScoreChange); + } + [TestMethod] public void Put_WithStaleOperationGeneration_DoesNotStore() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs index 0aab4aaa..9947a0e1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs @@ -7,6 +7,7 @@ namespace Codescene.VSExtension.Core.Tests { [TestClass] + [DoNotParallelize] public class DeltaJobTrackerTests { [TestInitialize] @@ -143,6 +144,29 @@ public void RunningJobs_IsReadOnly() Assert.IsInstanceOfType(jobs, typeof(IReadOnlyCollection)); } + [TestMethod] + public void Replace_WithJobs_ReplacesCurrentJobs() + { + var oldJob = CreateJob("old.cs"); + var newJob = CreateJob("new.cs"); + DeltaJobTracker.Add(oldJob); + + DeltaJobTracker.Replace(new[] { newJob }); + + Assert.IsFalse(DeltaJobTracker.RunningJobs.Contains(oldJob)); + Assert.IsTrue(DeltaJobTracker.RunningJobs.Contains(newJob)); + } + + [TestMethod] + public void Replace_WithNull_ClearsCurrentJobs() + { + DeltaJobTracker.Add(CreateJob()); + + DeltaJobTracker.Replace(null); + + Assert.IsEmpty(DeltaJobTracker.RunningJobs); + } + [TestMethod] public void Clear_WithRunningJobs_RemovesAllJobs() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaTelemetryHelperTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaTelemetryHelperTests.cs index 9c1651cf..65b51423 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaTelemetryHelperTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaTelemetryHelperTests.cs @@ -114,6 +114,23 @@ public async Task HandleDeltaTelemetryEvent_FileRemoved_DoesNotIncludeAdditional Assert.IsNull(capturedData); } + [TestMethod] + public async Task HandleDeltaTelemetryEvent_FileRemovedWithNullDelta_SendsMonitorFileRemovedEvent() + { + var (previousSnapshot, currentCache) = CreateSnapshots(new[] { "removedfile.cs" }, new string[] { }); + var entry = new DeltaCacheEntry("removedfile.cs", "old", "new", null); + + var callReceived = new ManualResetEventSlim(false); + _mockTelemetryManager + .Setup(t => t.SendTelemetryAsync(Constants.Telemetry.MONITORFILEREMOVED, It.IsAny>(), It.IsAny())) + .Callback(() => callReceived.Set()); + + DeltaTelemetryHelper.HandleDeltaTelemetryEvent(previousSnapshot, currentCache, entry, _mockTelemetryManager.Object); + + var signaled = await Task.Run(() => callReceived.Wait(AsyncTimeoutMs)); + Assert.IsTrue(signaled, "Timed out waiting for SendTelemetry(file_removed) to be called"); + } + [TestMethod] public void HandleDeltaTelemetryEvent_NullTelemetryManager_DoesNotThrow() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorDynamicIntervalTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorDynamicIntervalTests.cs deleted file mode 100644 index 266f87a9..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorDynamicIntervalTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Util; -using Moq; - -namespace Codescene.VSExtension.Core.Tests; - -[TestClass] -public class DroppingScheduledExecutorDynamicIntervalTests : DroppingScheduledExecutorTestBase -{ - [TestMethod] - public void GetInterval_ReturnsInitialInterval() - { - var interval = TimeSpan.FromSeconds(9); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, interval, _mockLogger.Object); - - Assert.AreEqual(interval, _executor.GetInterval()); - } - - [TestMethod] - public void SetInterval_UpdatesInterval() - { - var initialInterval = TimeSpan.FromSeconds(9); - var newInterval = TimeSpan.FromSeconds(20); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, initialInterval, _mockLogger.Object); - - _executor.SetInterval(newInterval); - - Assert.AreEqual(newInterval, _executor.GetInterval()); - } - - [TestMethod] - public void SetInterval_CanSetSmallerInterval() - { - var initialInterval = TimeSpan.FromSeconds(9); - var smallerInterval = TimeSpan.FromSeconds(5); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, initialInterval, _mockLogger.Object); - - _executor.SetInterval(smallerInterval); - - Assert.AreEqual(smallerInterval, _executor.GetInterval()); - } - - [TestMethod] - public void SetInterval_WhenDisposed_DoesNothing() - { - var initialInterval = TimeSpan.FromSeconds(9); - var newInterval = TimeSpan.FromSeconds(20); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, initialInterval, _mockLogger.Object); - - _executor.Dispose(); - _executor.SetInterval(newInterval); - - Assert.AreEqual(initialInterval, _executor.GetInterval()); - } - - [TestMethod] - public void SetInterval_LogsIntervalUpdate() - { - var initialInterval = TimeSpan.FromSeconds(9); - var newInterval = TimeSpan.FromSeconds(20); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, initialInterval, _mockLogger.Object); - - _executor.SetInterval(newInterval); - - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("interval updated to") && s.Contains("20"))), Times.Once); - } - - [TestMethod] - public async Task SetInterval_WhenRunning_RestartsTimerWithNewInterval() - { - var executionCount = 0; - var initialInterval = TimeSpan.FromMilliseconds(100); - var newInterval = TimeSpan.FromMilliseconds(50); - var thirdExecutionSignal = new TaskCompletionSource(); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - executionCount++; - if (executionCount >= 3) - { - thirdExecutionSignal.TrySetResult(true); - } - }, - CancellationToken.None, - initialInterval, - _mockLogger.Object); - - _executor.Start(); - await Task.Delay(TimeSpan.FromMilliseconds(150)); - Assert.IsGreaterThanOrEqualTo(executionCount, 1); - - _executor.SetInterval(newInterval); - Assert.AreEqual(newInterval, _executor.GetInterval()); - - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); - var completedTask = await Task.WhenAny(thirdExecutionSignal.Task, timeoutTask); - Assert.AreNotEqual(timeoutTask, completedTask, "Executor should continue running after interval change"); - } - - [TestMethod] - public void SetInterval_WhenNotStarted_UpdatesIntervalWithoutStartingTimer() - { - var initialInterval = TimeSpan.FromSeconds(9); - var newInterval = TimeSpan.FromSeconds(20); - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, initialInterval, _mockLogger.Object); - - _executor.SetInterval(newInterval); - - Assert.AreEqual(newInterval, _executor.GetInterval()); - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("started"))), Times.Never); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorExecutionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorExecutionTests.cs deleted file mode 100644 index 46ab95d4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorExecutionTests.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Util; -using Moq; - -namespace Codescene.VSExtension.Core.Tests; - -[TestClass] -public class DroppingScheduledExecutorExecutionTests : DroppingScheduledExecutorTestBase -{ - [TestMethod] - public async Task Start_ExecutesActionAfterInterval() - { - var actionExecutedSignal = new TaskCompletionSource(); - var interval = TimeSpan.FromMilliseconds(100); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - actionExecutedSignal.TrySetResult(true); - }, - CancellationToken.None, - interval, - _mockLogger.Object); - - _executor.Start(); - - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); - var completedTask = await Task.WhenAny(actionExecutedSignal.Task, timeoutTask); - - Assert.AreNotEqual(timeoutTask, completedTask, "Action was not executed within timeout"); - Assert.IsTrue(await actionExecutedSignal.Task); - } - - [TestMethod] - public async Task Stop_WhenStarted_StopsTimer() - { - var executionCount = 0; - var interval = TimeSpan.FromMilliseconds(100); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - executionCount++; - }, - CancellationToken.None, - interval, - _mockLogger.Object); - - _executor.Start(); - await Task.Delay(TimeSpan.FromMilliseconds(250)); - _executor.Stop(); - - var countAfterStop = executionCount; - await Task.Delay(TimeSpan.FromMilliseconds(300)); - - Assert.AreEqual(countAfterStop, executionCount, "Action should not execute after stop"); - } - - [TestMethod] - public async Task OnTimerCallback_DropsExecution_WhenPreviousStillRunning() - { - var firstExecutionStarted = new TaskCompletionSource(); - var firstExecutionCanComplete = new TaskCompletionSource(); - var executionCount = 0; - var interval = TimeSpan.FromMilliseconds(50); - - _executor = new DroppingScheduledExecutor( - async ct => - { - executionCount++; - if (executionCount == 1) - { - firstExecutionStarted.TrySetResult(true); - await firstExecutionCanComplete.Task; - } - }, - CancellationToken.None, - interval, - _mockLogger.Object); - - _executor.Start(); - - await firstExecutionStarted.Task; - await Task.Delay(TimeSpan.FromMilliseconds(200)); - - firstExecutionCanComplete.TrySetResult(true); - await Task.Delay(TimeSpan.FromMilliseconds(100)); - - _mockLogger.Verify( - l => l.Debug(It.Is(s => s.Contains("dropping execution"))), - Times.AtLeastOnce); - } - - [TestMethod] - public async Task OnTimerCallback_LogsError_WhenActionThrows() - { - var actionExecutedSignal = new TaskCompletionSource(); - var testException = new InvalidOperationException("Test exception"); - var interval = TimeSpan.FromMilliseconds(100); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - actionExecutedSignal.TrySetResult(true); - throw testException; - }, - CancellationToken.None, - interval, - _mockLogger.Object); - - _executor.Start(); - - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); - var completedTask = await Task.WhenAny(actionExecutedSignal.Task, timeoutTask); - - Assert.AreNotEqual(timeoutTask, completedTask, "Action was not executed within timeout"); - await Task.Delay(TimeSpan.FromMilliseconds(50)); - - _executor.Stop(); - - _mockLogger.Verify( - l => l.Error( - It.Is(s => s.Contains("error executing")), - It.Is(ex => ex == testException)), - Times.AtLeastOnce); - } - - [TestMethod] - public async Task OnTimerCallback_ContinuesAfterException() - { - var executionCount = 0; - var secondExecutionSignal = new TaskCompletionSource(); - var interval = TimeSpan.FromMilliseconds(100); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - executionCount++; - if (executionCount == 1) - { - throw new InvalidOperationException("First execution error"); - } - else if (executionCount == 2) - { - secondExecutionSignal.TrySetResult(true); - } - }, - CancellationToken.None, - interval, - _mockLogger.Object); - - _executor.Start(); - - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); - var completedTask = await Task.WhenAny(secondExecutionSignal.Task, timeoutTask); - - Assert.AreNotEqual(timeoutTask, completedTask, "Second execution did not complete within timeout"); - Assert.IsGreaterThanOrEqualTo(executionCount, 2, "Executor should continue after exception"); - } - - [TestMethod] - public async Task OnTimerCallback_WhenTokenAlreadyCancelled_DoesNotExecuteAction() - { - using (var cts = new CancellationTokenSource()) - { - cts.Cancel(); - var actionExecuted = false; - var interval = TimeSpan.FromMilliseconds(50); - - _executor = new DroppingScheduledExecutor( - async ct => - { - await Task.CompletedTask; - actionExecuted = true; - }, - cts.Token, - interval, - _mockLogger.Object); - - _executor.Start(); - await Task.Delay(interval + interval); - - Assert.IsFalse(actionExecuted, "Action should not run when token is already cancelled"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorLifecycleTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorLifecycleTests.cs deleted file mode 100644 index 8c4ce71c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorLifecycleTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Util; -using Moq; - -namespace Codescene.VSExtension.Core.Tests; - -[TestClass] -public class DroppingScheduledExecutorLifecycleTests : DroppingScheduledExecutorTestBase -{ - [TestMethod] - public void Constructor_NullAction_ThrowsArgumentNullException() - { - Assert.Throws(() => - new DroppingScheduledExecutor(null, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object)); - } - - [TestMethod] - public void Constructor_NullLogger_ThrowsArgumentNullException() - { - Assert.Throws(() => - new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), null)); - } - - [TestMethod] - public void Start_WhenDisposed_DoesNothing() - { - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object); - - _executor.Dispose(); - - _executor.Start(); - } - - [TestMethod] - public void Start_WhenAlreadyStarted_LogsWarningAndReturns() - { - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object); - - _executor.Start(); - _executor.Start(); - - _mockLogger.Verify(l => l.Warn(It.Is(s => s.Contains("already started"))), Times.Once); - } - - [TestMethod] - public void Stop_WhenNotStarted_DoesNotThrow() - { - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object); - - _executor.Stop(); - } - - [TestMethod] - public void Dispose_StopsTimer() - { - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object); - - _executor.Start(); - _executor.Dispose(); - - _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("disposed"))), Times.Once); - } - - [TestMethod] - public void Dispose_MultipleCalls_DoesNotThrow() - { - _executor = new DroppingScheduledExecutor(ct => Task.CompletedTask, CancellationToken.None, TimeSpan.FromMilliseconds(100), _mockLogger.Object); - - _executor.Dispose(); - _executor.Dispose(); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorRaceConditionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorRaceConditionTests.cs deleted file mode 100644 index 565d866f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorRaceConditionTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Moq; - -namespace Codescene.VSExtension.Core.Tests; - -[TestClass] -public class DroppingScheduledExecutorRaceConditionTests -{ - private Mock _mockLogger; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - } - - [TestMethod] - public async Task Stop_ActionExecutesAfterStopReturns_RaceCondition() - { - long stopReturnTime; - var executionStartTimes = new List(); - var executionTimesLock = new object(); - - Func action = async ct => - { - var startTime = Stopwatch.GetTimestamp(); - lock (executionTimesLock) - { - executionStartTimes.Add(startTime); - } - - await Task.Delay(5); - }; - - using (var executor = new DroppingScheduledExecutor(action, CancellationToken.None, TimeSpan.FromMilliseconds(10), _mockLogger.Object)) - { - executor.Start(); - - await Task.Delay(50); - - executor.Stop(); - stopReturnTime = Stopwatch.GetTimestamp(); - - await Task.Delay(100); - } - - lock (executionTimesLock) - { - var executionsAfterStop = executionStartTimes.FindAll(t => t > stopReturnTime); - Assert.IsLessThanOrEqualTo( - 1, - executionsAfterStop.Count, - $"Bug detected: {executionsAfterStop.Count} action(s) started executing after Stop() returned (at most one in-flight allowed)"); - } - } - - [TestMethod] - public async Task Stop_ActionExecutesAfterStopReturns_AggressiveRaceCondition() - { - var failures = 0; - var iterations = 10; - - for (int i = 0; i < iterations; i++) - { - long stopReturnTime; - var executionStartTimes = new List(); - var executionTimesLock = new object(); - - Func action = async ct => - { - var startTime = Stopwatch.GetTimestamp(); - lock (executionTimesLock) - { - executionStartTimes.Add(startTime); - } - - await Task.Delay(5); - }; - - using (var executor = new DroppingScheduledExecutor(action, CancellationToken.None, TimeSpan.FromMilliseconds(5), _mockLogger.Object)) - { - executor.Start(); - - await Task.Delay(30); - - executor.Stop(); - stopReturnTime = Stopwatch.GetTimestamp(); - - await Task.Delay(50); - } - - lock (executionTimesLock) - { - var executionsAfterStop = executionStartTimes.FindAll(t => t > stopReturnTime); - if (executionsAfterStop.Count > 1) - { - failures++; - } - } - } - - Assert.AreEqual( - 0, - failures, - $"Bug detected: {failures}/{iterations} iterations had more than one action execute after Stop() returned"); - } - - [TestMethod] - public async Task Dispose_ActionExecutesAfterDisposeReturns_RaceCondition() - { - var executionStartTimes = new List(); - var executionTimesLock = new object(); - - Func action = async ct => - { - var startTime = Stopwatch.GetTimestamp(); - lock (executionTimesLock) - { - executionStartTimes.Add(startTime); - } - - await Task.Delay(5); - }; - - var executor = new DroppingScheduledExecutor(action, CancellationToken.None, TimeSpan.FromMilliseconds(10), _mockLogger.Object); - executor.Start(); - - await Task.Delay(50); - - executor.Dispose(); - var disposeReturnTime = Stopwatch.GetTimestamp(); - - await Task.Delay(100); - - lock (executionTimesLock) - { - var executionsAfterDispose = executionStartTimes.FindAll(t => t > disposeReturnTime); - Assert.IsLessThanOrEqualTo( - 1, - executionsAfterDispose.Count, - $"Bug detected: {executionsAfterDispose.Count} action(s) started executing after Dispose() returned (at most one in-flight allowed)"); - } - } - - [TestMethod] - public async Task Dispose_ActionExecutesAfterDisposeReturns_AggressiveRaceCondition() - { - var failures = 0; - var iterations = 10; - - for (int i = 0; i < iterations; i++) - { - var executionStartTimes = new List(); - var executionTimesLock = new object(); - - Func action = async ct => - { - var startTime = Stopwatch.GetTimestamp(); - lock (executionTimesLock) - { - executionStartTimes.Add(startTime); - } - - await Task.Delay(5); - }; - - var executor = new DroppingScheduledExecutor(action, CancellationToken.None, TimeSpan.FromMilliseconds(5), _mockLogger.Object); - executor.Start(); - - await Task.Delay(30); - - executor.Dispose(); - var disposeReturnTime = Stopwatch.GetTimestamp(); - - await Task.Delay(50); - - lock (executionTimesLock) - { - var executionsAfterDispose = executionStartTimes.FindAll(t => t > disposeReturnTime); - if (executionsAfterDispose.Count > 1) - { - failures++; - } - } - } - - Assert.AreEqual( - 0, - failures, - $"Bug detected: {failures}/{iterations} iterations had more than one action execute after Dispose() returned"); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorTestBase.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorTestBase.cs deleted file mode 100644 index d1ae7c30..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorTestBase.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Moq; - -namespace Codescene.VSExtension.Core.Tests; - -public abstract class DroppingScheduledExecutorTestBase -{ - protected Mock _mockLogger; - protected DroppingScheduledExecutor _executor; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - } - - [TestCleanup] - public void Cleanup() - { - _executor?.Dispose(); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FakeLogger.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FakeLogger.cs new file mode 100644 index 00000000..c339399f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FakeLogger.cs @@ -0,0 +1,47 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Interfaces; + +namespace Codescene.VSExtension.Core.Tests +{ + public sealed class FakeLogger : ILogger + { + public List Errors { get; } = new List(); + + public List Warnings { get; } = new List(); + + public List Infos { get; } = new List(); + + public List Debugs { get; } = new List(); + + public List SnapshotErrorMessages() + { + return Errors.ToList(); + } + + public List SnapshotInfoMessages() + { + return Infos.ToList(); + } + + public void Error(string message, Exception ex) + { + Errors.Add(message); + } + + public void Warn(string message, bool statusBar = false) + { + Warnings.Add(message); + } + + public void Info(string message, bool statusBar = false) + { + Infos.Add(message); + } + + public void Debug(string message) + { + Debugs.Add(message); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorConcurrencyTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorConcurrencyTests.cs deleted file mode 100644 index c35ffc67..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorConcurrencyTests.cs +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Enums.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class FileChangeEventProcessorConcurrencyTests - { - [TestMethod] - public async Task ProcessQueuedEvents_WhenAlreadyProcessing_SkipsTimerTick() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var getChangedFilesTcs = new TaskCompletionSource>(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => getChangedFilesTcs.Task; - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - await Task.Delay(100); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - await Task.Delay(100); - - getChangedFilesTcs.SetResult(new List { "file.cs" }); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - var paths = processedEvents.Select(e => e.FilePath).OrderBy(p => p).ToList(); - Assert.AreEqual("first.cs", paths[0]); - Assert.AreEqual("second.cs", paths[1]); - } - - [TestMethod] - public async Task ProcessQueuedEvents_AfterProcessingCompletes_ProcessesNewEvents() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - - Assert.HasCount(1, processedEvents); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - Assert.AreEqual("first.cs", processedEvents[0].FilePath); - Assert.AreEqual("second.cs", processedEvents[1].FilePath); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenProcessingThrows_FlagIsReset() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var shouldThrow = true; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - if (shouldThrow) - { - shouldThrow = false; - throw new InvalidOperationException("simulated error"); - } - - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - await Task.Delay(200); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual("second.cs", processedEvents[0].FilePath); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenTickDelayed_ReusesCachedBaselineCommit() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var getChangedFilesTcs = new TaskCompletionSource>(); - var baselineCommitCallCount = 0; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => getChangedFilesTcs.Task; - - string GetBaselineCommit() - { - Interlocked.Increment(ref baselineCommitCallCount); - return "abc123"; - } - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - getBaselineCommitCallback: GetBaselineCommit)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - await Task.Delay(100); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - await Task.Delay(100); - - getChangedFilesTcs.SetResult(new List { "file.cs" }); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - Assert.AreEqual(1, baselineCommitCallCount, "GetBaselineCommit should only be called once due to caching"); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenTickNotDelayed_ComputesFreshBaselineCommit() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var baselineCommitCallCount = 0; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - string GetBaselineCommit() - { - Interlocked.Increment(ref baselineCommitCallCount); - return "abc123"; - } - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - getBaselineCommitCallback: GetBaselineCommit)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - - await Task.Delay(100); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - Assert.AreEqual(2, baselineCommitCallCount, "GetBaselineCommit should be called twice for non-delayed ticks"); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenGetChangedFilesThrows_ResetsProcessingFlagAndContinues() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var shouldThrow = true; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) - { - if (shouldThrow) - { - shouldThrow = false; - throw new InvalidOperationException("simulated GetChangedFiles error"); - } - - return Task.FromResult(new List()); - } - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(50), CancellationToken.None); - - await Task.Delay(200); - - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual("second.cs", processedEvents[0].FilePath); - - bool IsExpectedError((string Message, Exception Ex) entry) => - entry.Message.Contains("Error processing queued events") - && entry.Ex != null - && entry.Ex.Message.Contains("simulated GetChangedFiles error"); - - Assert.IsTrue(logger.SnapshotErrorMessages().Any(IsExpectedError), "Expected outer error handler to log 'Error processing queued events'"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorTests.cs deleted file mode 100644 index 47acfc2f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorTests.cs +++ /dev/null @@ -1,509 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Enums.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class FileChangeEventProcessorTests - { - [TestMethod] - public async Task ProcessQueuedEvents_InvokesProcessEventCallback() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processEventInvoked = false; - FileChangeEvent capturedEvt = null; - List capturedChangedFiles = null; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processEventInvoked = true; - capturedEvt = evt; - capturedChangedFiles = changedFiles; - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List { "file1.cs" }); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "test.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (!processEventInvoked && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.IsTrue(processEventInvoked, "ProcessEvent callback should have been invoked within 5 seconds"); - Assert.IsNotNull(capturedEvt); - Assert.AreEqual(FileChangeType.Change, capturedEvt.Type); - Assert.AreEqual("test.cs", capturedEvt.FilePath); - Assert.IsNotNull(capturedChangedFiles); - Assert.HasCount(1, capturedChangedFiles); - Assert.AreEqual("file1.cs", capturedChangedFiles[0]); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenCallbackThrows_LogsError() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - - bool IsExpectedError((string Message, Exception Ex) entry) => - entry.Message.Contains("Error processing file change event") - && entry.Ex != null - && entry.Ex.Message.Contains("simulated error"); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) => - throw new InvalidOperationException("simulated error"); - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "test.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (DateTime.UtcNow < deadline && !logger.SnapshotErrorMessages().Any(IsExpectedError)) - { - await Task.Delay(50); - } - } - - Assert.IsTrue(logger.SnapshotErrorMessages().Any(IsExpectedError), "Expected Error for callback throw (ProcessOneEventAsync catch)"); - } - - [TestMethod] - public async Task ProcessQueuedEvents_CoalescesMultipleEventsForSamePath_LastEventWins() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "same.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "same.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "same.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count == 0 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual(FileChangeType.Delete, processedEvents[0].Type); - Assert.AreEqual("same.cs", processedEvents[0].FilePath); - } - - [TestMethod] - public async Task ProcessQueuedEvents_CoalescesCreateAndChange_ToSingleChangeEvent() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "new.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "new.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count == 0 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual(FileChangeType.Change, processedEvents[0].Type); - Assert.AreEqual("new.cs", processedEvents[0].FilePath); - } - - [TestMethod] - public async Task ProcessQueuedEvents_CoalescesDeleteFollowedByCreateChange_EmitsDelete() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "file.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "file.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "file.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count == 0 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual(FileChangeType.Delete, processedEvents[0].Type, "Delete should not be masked by subsequent Create/Change"); - } - - [TestMethod] - public async Task ProcessQueuedEvents_DoesNotCoalesceDifferentPaths() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "a.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "b.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - var paths = processedEvents.Select(e => e.FilePath).OrderBy(p => p).ToList(); - Assert.AreEqual("a.cs", paths[0]); - Assert.AreEqual("b.cs", paths[1]); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenCancellationRequested_ExitsWithoutProcessing() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processEventInvoked = false; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processEventInvoked = true; - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var cts = new CancellationTokenSource()) - { - cts.Cancel(); - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "test.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), cts.Token); - await Task.Delay(350); - } - } - - Assert.IsFalse(processEventInvoked); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenQueueEmpty_ExitsWithoutInvokingCallback() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processEventInvoked = false; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processEventInvoked = true; - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - await Task.Delay(350); - } - - Assert.IsFalse(processEventInvoked); - } - - [TestMethod] - public async Task ProcessOneEventAsync_WhenTokenCancelled_ExitsWithoutInvokingCallback() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var firstInvoked = false; - var secondInvoked = false; - - using (var cts = new CancellationTokenSource()) - { - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - if (evt.FilePath == "first.cs") - { - firstInvoked = true; - cts.Cancel(); - } - else if (evt.FilePath == "second.cs") - { - secondInvoked = true; - } - - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor(logger, taskScheduler, ProcessEvent, GetChangedFiles)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "first.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), cts.Token); - await Task.Delay(100); - } - } - - Assert.IsTrue(firstInvoked); - Assert.IsFalse(secondInvoked); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenShouldSkipReturnsTrue_SkipsNonDeleteEvents() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - shouldSkipNonDeletesCallback: () => true)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "create.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "change.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "delete.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count == 0 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - - await Task.Delay(100); - } - - Assert.HasCount(1, processedEvents); - Assert.AreEqual(FileChangeType.Delete, processedEvents[0].Type); - Assert.AreEqual("delete.cs", processedEvents[0].FilePath); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenShouldSkipReturnsTrue_StillProcessesDeleteEvents() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - shouldSkipNonDeletesCallback: () => true)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "first.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "second.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - Assert.IsTrue(processedEvents.All(e => e.Type == FileChangeType.Delete)); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenShouldSkipReturnsFalse_ProcessesAllEvents() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - shouldSkipNonDeletesCallback: () => false)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "create.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "change.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, "delete.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 3 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(3, processedEvents); - } - - [TestMethod] - public async Task ProcessQueuedEvents_WhenShouldSkipCallbackIsNull_ProcessesAllEvents() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - shouldSkipNonDeletesCallback: null)) - { - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "create.cs")); - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "change.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 2 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - } - - Assert.HasCount(2, processedEvents); - } - - [TestMethod] - public async Task ProcessQueuedEvents_DynamicSkipCallback_ReEvaluatesOnEachProcessingCycle() - { - var logger = new FakeLogger(); - var taskScheduler = new FakeAsyncTaskScheduler(); - var processedEvents = new List(); - var skipState = false; - var callbackInvocationCount = 0; - - Task ProcessEvent(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken ct, string baselineCommit) - { - processedEvents.Add(evt); - return Task.CompletedTask; - } - - Task> GetChangedFiles(string baselineCommit) => Task.FromResult(new List()); - - using (var processor = new FileChangeEventProcessor( - logger, - taskScheduler, - ProcessEvent, - GetChangedFiles, - shouldSkipNonDeletesCallback: () => - { - callbackInvocationCount++; - return skipState; - })) - { - skipState = true; - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, "first.cs")); - processor.Start(TimeSpan.FromMilliseconds(10), CancellationToken.None); - var deadline = DateTime.UtcNow.AddSeconds(5); - while (callbackInvocationCount < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - - await Task.Delay(100); - - Assert.HasCount(0, processedEvents, "First event should be skipped when callback returns true"); - Assert.AreEqual(1, callbackInvocationCount, "Callback should have been invoked for first processing cycle"); - - skipState = false; - processor.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, "second.cs")); - deadline = DateTime.UtcNow.AddSeconds(5); - while (processedEvents.Count < 1 && DateTime.UtcNow < deadline) - { - await Task.Delay(20); - } - - await Task.Delay(100); - - Assert.HasCount(1, processedEvents, "Second event should be processed when callback returns false"); - Assert.AreEqual(FileChangeType.Change, processedEvents[0].Type); - Assert.AreEqual("second.cs", processedEvents[0].FilePath); - Assert.AreEqual(2, callbackInvocationCount, "Callback should have been invoked for second processing cycle"); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventTests.cs deleted file mode 100644 index 59648fbc..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Enums.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class FileChangeEventTests - { - [TestMethod] - public void Constructor_InitializesType() - { - var fileEvent = new FileChangeEvent(FileChangeType.Create, "test.cs"); - - Assert.AreEqual(FileChangeType.Create, fileEvent.Type); - } - - [TestMethod] - public void Constructor_InitializesFilePath() - { - var filePath = "C:\\test\\file.cs"; - var fileEvent = new FileChangeEvent(FileChangeType.Change, filePath); - - Assert.AreEqual(filePath, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithCreateType_StoresCorrectly() - { - var fileEvent = new FileChangeEvent(FileChangeType.Create, "newfile.cs"); - - Assert.AreEqual(FileChangeType.Create, fileEvent.Type); - Assert.AreEqual("newfile.cs", fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithChangeType_StoresCorrectly() - { - var fileEvent = new FileChangeEvent(FileChangeType.Change, "modified.cs"); - - Assert.AreEqual(FileChangeType.Change, fileEvent.Type); - Assert.AreEqual("modified.cs", fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithDeleteType_StoresCorrectly() - { - var fileEvent = new FileChangeEvent(FileChangeType.Delete, "deleted.cs"); - - Assert.AreEqual(FileChangeType.Delete, fileEvent.Type); - Assert.AreEqual("deleted.cs", fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithAbsolutePath_StoresCorrectly() - { - var absolutePath = Path.Combine("C:", "Users", "test", "file.cs"); - var fileEvent = new FileChangeEvent(FileChangeType.Change, absolutePath); - - Assert.AreEqual(absolutePath, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithRelativePath_StoresCorrectly() - { - var relativePath = "src/test.cs"; - var fileEvent = new FileChangeEvent(FileChangeType.Create, relativePath); - - Assert.AreEqual(relativePath, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithPathWithoutExtension_StoresCorrectly() - { - var pathWithoutExtension = "mydir"; - var fileEvent = new FileChangeEvent(FileChangeType.Delete, pathWithoutExtension); - - Assert.AreEqual(pathWithoutExtension, fileEvent.FilePath); - } - - [TestMethod] - public void Type_ReturnsInitializedValue() - { - var fileEvent = new FileChangeEvent(FileChangeType.Change, "test.cs"); - - var type = fileEvent.Type; - - Assert.AreEqual(FileChangeType.Change, type); - } - - [TestMethod] - public void FilePath_ReturnsInitializedValue() - { - var expectedPath = "C:\\test\\file.cs"; - var fileEvent = new FileChangeEvent(FileChangeType.Create, expectedPath); - - var actualPath = fileEvent.FilePath; - - Assert.AreEqual(expectedPath, actualPath); - } - - [TestMethod] - public void Constructor_WithEmptyString_StoresEmptyString() - { - var fileEvent = new FileChangeEvent(FileChangeType.Create, string.Empty); - - Assert.AreEqual(string.Empty, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithPathContainingSpaces_StoresCorrectly() - { - var pathWithSpaces = "C:\\My Documents\\test file.cs"; - var fileEvent = new FileChangeEvent(FileChangeType.Change, pathWithSpaces); - - Assert.AreEqual(pathWithSpaces, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithUnixStylePath_StoresCorrectly() - { - var unixPath = "/home/user/test.cs"; - var fileEvent = new FileChangeEvent(FileChangeType.Create, unixPath); - - Assert.AreEqual(unixPath, fileEvent.FilePath); - } - - [TestMethod] - public void Constructor_WithMultipleExtensions_StoresCorrectly() - { - var multiExtPath = "archive.tar.gz"; - var fileEvent = new FileChangeEvent(FileChangeType.Delete, multiExtPath); - - Assert.AreEqual(multiExtPath, fileEvent.FilePath); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerDeletionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerDeletionTests.cs deleted file mode 100644 index c772b61c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerDeletionTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class FileChangeHandlerDeletionTests - { - private string _testWorkspacePath; - private FileChangeHandler _handler; - private FakeLogger _fakeLogger; - private FakeCodeReviewer _fakeCodeReviewer; - private FakeSupportedFileChecker _fakeSupportedFileChecker; - private TrackerManager _trackerManager; - - [TestInitialize] - public void Setup() - { - _testWorkspacePath = Path.Combine(Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testWorkspacePath); - - _fakeLogger = new FakeLogger(); - _fakeCodeReviewer = new FakeCodeReviewer(); - _fakeSupportedFileChecker = new FakeSupportedFileChecker(); - _trackerManager = new TrackerManager(); - - _handler = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new[] { _testWorkspacePath }, - _trackerManager, - new FakeGitService(), - _testWorkspacePath); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testWorkspacePath)) - { - try - { - Directory.Delete(_testWorkspacePath, true); - } - catch - { - } - } - } - - [TestMethod] - public async Task HandleFileDeleteAsync_UntrackedFileInChangedList_FiresDeleteEvent() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - - var eventFired = false; - string? deletedPath = null; - _handler.FileDeletedFromGit += (sender, e) => - { - eventFired = true; - deletedPath = e; - }; - - await _handler.HandleFileDeleteAsync(testFile, changedFiles); - - Assert.IsTrue(eventFired); - Assert.AreEqual(testFile, deletedPath); - } - - [TestMethod] - public async Task HandleFileDeleteAsync_UntrackedFileNotInChangedList_NoEventFired() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "other.cs" }; - - var eventFired = false; - _handler.FileDeletedFromGit += (sender, e) => - { - eventFired = true; - }; - - await _handler.HandleFileDeleteAsync(testFile, changedFiles); - - Assert.IsFalse(eventFired); - } - - [TestMethod] - public async Task HandleFileDeleteAsync_TrackedFile_RemovesFromTrackerAndFiresEvent() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - _trackerManager.Add(testFile); - - var eventFired = false; - _handler.FileDeletedFromGit += (sender, e) => - { - eventFired = true; - }; - - await _handler.HandleFileDeleteAsync(testFile, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(testFile)); - Assert.IsTrue(eventFired); - } - - [TestMethod] - public async Task HandleFileDeleteAsync_Directory_RemovesAllFilesInDirectory() - { - var subdir = Path.Combine(_testWorkspacePath, "subdir"); - var file1 = Path.Combine(subdir, "file1.cs"); - var file2 = Path.Combine(subdir, "file2.cs"); - - _trackerManager.Add(file1); - _trackerManager.Add(file2); - - var deletedFiles = new List(); - _handler.FileDeletedFromGit += (sender, e) => - { - deletedFiles.Add(e); - }; - - var changedFiles = new List(); - await _handler.HandleFileDeleteAsync(subdir, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(file1)); - Assert.IsFalse(_trackerManager.Contains(file2)); - Assert.HasCount(2, deletedFiles); - } - - [TestMethod] - public async Task HandleFileDeleteAsync_EventHandlerThrowsException_LogsError() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - - _handler.FileDeletedFromGit += (sender, e) => - { - throw new Exception("Test exception"); - }; - - await _handler.HandleFileDeleteAsync(testFile, changedFiles); - - Assert.IsTrue(_fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error firing file deleted event"))); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerTests.cs deleted file mode 100644 index 7efc30f0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerTests.cs +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class FileChangeHandlerTests - { - private string _testWorkspacePath; - private FileChangeHandler _handler; - private FakeLogger _fakeLogger; - private FakeCodeReviewer _fakeCodeReviewer; - private FakeSupportedFileChecker _fakeSupportedFileChecker; - private TrackerManager _trackerManager; - - [TestInitialize] - public void Setup() - { - _testWorkspacePath = Path.Combine(Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testWorkspacePath); - - _fakeLogger = new FakeLogger(); - _fakeCodeReviewer = new FakeCodeReviewer(); - _fakeSupportedFileChecker = new FakeSupportedFileChecker(); - _trackerManager = new TrackerManager(); - - _handler = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new[] { _testWorkspacePath }, - _trackerManager, - new FakeGitService(), - _testWorkspacePath); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testWorkspacePath)) - { - try - { - Directory.Delete(_testWorkspacePath, true); - } - catch - { - } - } - } - - [TestMethod] - public void SetWorkspacePaths_UpdatesPaths() - { - _handler.SetWorkspacePaths(new[] { _testWorkspacePath }); - _handler.SetWorkspacePaths(null); - _handler.SetWorkspacePaths(Array.Empty()); - } - - [TestMethod] - public async Task HandleFileChangeAsync_DirectoryPath_ReturnsEarly() - { - var directoryPath = Path.Combine(_testWorkspacePath, "mydir"); - var changedFiles = new List { "mydir" }; - - await _handler.HandleFileChangeAsync(directoryPath, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(directoryPath)); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount); - } - - [TestMethod] - public async Task HandleFileChangeAsync_DirectoryPathWithSeparator_ReturnsEarly() - { - var directoryPath = Path.Combine(_testWorkspacePath, "mydir") + Path.DirectorySeparatorChar; - var changedFiles = new List { "mydir/" }; - - await _handler.HandleFileChangeAsync(directoryPath, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(directoryPath)); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount); - } - - [TestMethod] - public async Task HandleFileChangeAsync_NonExistentFile_NoReviewCalled() - { - var nonExistentFile = Path.Combine(_testWorkspacePath, "nonexistent.cs"); - var changedFiles = new List { "nonexistent.cs" }; - - await _handler.HandleFileChangeAsync(nonExistentFile, changedFiles); - - Assert.IsTrue(_trackerManager.Contains(nonExistentFile)); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount); - } - - [TestMethod] - public async Task HandleFileChangeAsync_FileReadThrowsException_LogsWarning() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - File.WriteAllText(testFile, "public class Test {}"); - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - await Task.Delay(100); - File.Delete(testFile); - await Task.Delay(100); - - Assert.IsTrue(_fakeLogger.SnapshotWarnMessages().Count > 0 || _fakeCodeReviewer.ReviewCallCount == 1); - } - - [TestMethod] - public async Task HandleFileChangeAsync_CodeReviewerThrowsException_LogsError() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - File.WriteAllText(testFile, "public class Test {}"); - - _fakeCodeReviewer.ThrowOnReview = true; - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - await Task.Delay(200); - - Assert.IsTrue(_fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Could not load file for review"))); - } - - [TestMethod] - public async Task HandleFileChangeAsync_ValidFile_AddsToTrackerAndReviews() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - var changedFiles = new List { "test.cs" }; - File.WriteAllText(testFile, "public class Test {}"); - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - await Task.Delay(200); - - Assert.IsTrue(_trackerManager.Contains(testFile)); - Assert.IsGreaterThanOrEqualTo(_fakeCodeReviewer.ReviewCallCount, 1); - } - - [TestMethod] - public async Task HandleFileChangeAsync_WhenOpenDocumentContentProviderReturnsContent_UsesProviderContentNotDisk() - { - var testFile = Path.Combine(_testWorkspacePath, "open.cs"); - var changedFiles = new List { "open.cs" }; - File.WriteAllText(testFile, "content-from-disk"); - - var provider = new FakeOpenDocumentContentProvider(); - provider.SetContentForPath(testFile, "content-from-buffer"); - var handlerWithProvider = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new[] { _testWorkspacePath }, - _trackerManager, - new FakeGitService(), - _testWorkspacePath, - null, - provider); - - await handlerWithProvider.HandleFileChangeAsync(testFile, changedFiles); - - await Task.Delay(200); - - Assert.AreEqual(1, _fakeCodeReviewer.ReviewCallCount); - Assert.HasCount(1, _fakeCodeReviewer.ReviewedContents); - Assert.AreEqual("content-from-buffer", _fakeCodeReviewer.ReviewedContents[0]); - } - - [TestMethod] - public async Task HandleFileChangeAsync_FileNotInChangedList_ReturnsEarlyWithoutProcessing() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - File.WriteAllText(testFile, "public class Test {}"); - var changedFiles = new List { "other.cs" }; - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(testFile), "Should not add file to tracker"); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount, "Should not review file"); - } - - [TestMethod] - public async Task HandleFileChangeAsync_RevertedTrackedFile_RemovesFromTrackerAndFiresFileDeletedFromGit() - { - var testFile = Path.Combine(_testWorkspacePath, "test.cs"); - File.WriteAllText(testFile, "public class Test {}"); - _trackerManager.Add(testFile); - - var eventFired = false; - string? deletedPath = null; - _handler.FileDeletedFromGit += (sender, e) => - { - eventFired = true; - deletedPath = e; - }; - - var changedFiles = new List { "other.cs" }; - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(testFile)); - Assert.IsTrue(eventFired); - Assert.AreEqual(testFile, deletedPath); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount); - } - - [TestMethod] - public async Task HandleFileChangeAsync_UnsupportedFileType_ReturnsEarlyWithoutProcessing() - { - var testFile = Path.Combine(_testWorkspacePath, "test.txt"); - File.WriteAllText(testFile, "text content"); - var changedFiles = new List { "test.txt" }; - - _fakeSupportedFileChecker.SetSupported(testFile, false); - - await _handler.HandleFileChangeAsync(testFile, changedFiles); - - Assert.IsFalse(_trackerManager.Contains(testFile), "Should not add file to tracker"); - Assert.AreEqual(0, _fakeCodeReviewer.ReviewCallCount, "Should not review file"); - } - - [TestMethod] - public void ShouldProcessFile_WhenChangedListIsGitRelative_MatchesFileUnderWorkspace() - { - var fileUnderWorkspace = Path.Combine(_testWorkspacePath, "subdir", "inside.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(fileUnderWorkspace)); - File.WriteAllText(fileUnderWorkspace, "public class Inside {}"); - var changedFiles = new List { "subdir/inside.cs" }; - - var result = _handler.ShouldProcessFile(fileUnderWorkspace, changedFiles); - - Assert.IsTrue(result, "IsFileInChangedList should match when changed list contains git-relative path"); - } - - [TestMethod] - public void ShouldProcessFile_NullWorkspacePath_ReturnsTrue() - { - var handlerWithNullWorkspace = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - null, - _trackerManager, - new FakeGitService()); - - var testFile = "test.cs"; - var changedFiles = new List { "test.cs" }; - - var result = handlerWithNullWorkspace.ShouldProcessFile(testFile, changedFiles); - - Assert.IsTrue(result); - } - - [TestMethod] - public void ShouldProcessFile_EmptyWorkspacePaths_ReturnsTrue() - { - var handlerWithEmptyWorkspace = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - Array.Empty(), - _trackerManager, - new FakeGitService()); - - var testFile = "test.cs"; - var changedFiles = new List { "test.cs" }; - - var result = handlerWithEmptyWorkspace.ShouldProcessFile(testFile, changedFiles); - - Assert.IsTrue(result); - } - - [TestMethod] - public void ShouldProcessFile_IgnoredFile_ReturnsFalse() - { - var testFile = Path.Combine(_testWorkspacePath, "ignored.cs"); - var changedFiles = new List { "ignored.cs" }; - var handlerWithIgnoringGit = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new[] { _testWorkspacePath }, - _trackerManager, - new FakeGitServiceIgnorePath(testFile)); - - var result = handlerWithIgnoringGit.ShouldProcessFile(testFile, changedFiles); - - Assert.IsFalse(result); - } - - [TestMethod] - public void ShouldProcessFile_UnsupportedFile_ReturnsFalse() - { - var testFile = Path.Combine(_testWorkspacePath, "test.unsupported"); - var changedFiles = new List { "test.unsupported" }; - - _fakeSupportedFileChecker.SetSupported(testFile, false); - - var result = _handler.ShouldProcessFile(testFile, changedFiles); - - Assert.IsFalse(result); - } - - [TestMethod] - public async Task HandleFileChangeAsync_WhenOpenDocumentContentProviderThrows_FallsBackToDiskContent() - { - var testFile = Path.Combine(_testWorkspacePath, "fallback.cs"); - var changedFiles = new List { "fallback.cs" }; - File.WriteAllText(testFile, "content-from-disk"); - - var provider = new FakeOpenDocumentContentProvider { ThrowOnGetContent = true }; - var handlerWithProvider = new FileChangeHandler( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new[] { _testWorkspacePath }, - _trackerManager, - new FakeGitService(), - _testWorkspacePath, - null, - provider); - - await handlerWithProvider.HandleFileChangeAsync(testFile, changedFiles); - - await Task.Delay(200); - - Assert.IsTrue(_fakeLogger.SnapshotWarnMessages().Any(m => m.Contains("Open document provider failed"))); - Assert.AreEqual(1, _fakeCodeReviewer.ReviewCallCount); - Assert.HasCount(1, _fakeCodeReviewer.ReviewedContents); - Assert.AreEqual("content-from-disk", _fakeCodeReviewer.ReviewedContents[0]); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitBlobShaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitBlobShaTests.cs new file mode 100644 index 00000000..1eebd0e0 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitBlobShaTests.cs @@ -0,0 +1,28 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class GitBlobShaTests + { + [TestMethod] + public void FromUtf8_EmptyContent_MatchesGitBlobHash() + { + Assert.AreEqual("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", GitBlobSha.FromUtf8(string.Empty)); + } + + [TestMethod] + public void FromUtf8_KnownContent_MatchesGitBlobHash() + { + Assert.AreEqual("ce013625030ba8dba906f756967f9e9ca394464a", GitBlobSha.FromUtf8("hello\n")); + } + + [TestMethod] + public void FromUtf8_NullContent_MatchesEmptyBlob() + { + Assert.AreEqual(GitBlobSha.FromUtf8(string.Empty), GitBlobSha.FromUtf8(null)); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorChangedFilesTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorChangedFilesTests.cs deleted file mode 100644 index e8fa90a2..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorChangedFilesTests.cs +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Reflection; -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeDetectorChangedFilesTests : GitChangeDetectorTestBase - { - [TestMethod] - public void IsFileInAnyWorkspace_NullOrEmptyWorkspacePaths_ReturnsTrue() - { - var method = typeof(GitChangeDetector).GetMethod( - "IsFileInAnyWorkspace", - BindingFlags.NonPublic | BindingFlags.Instance, - null, - new[] { typeof(string), typeof(string), typeof(IReadOnlyCollection) }, - null); - Assert.IsNotNull(method); - var resultNull = method.Invoke(_detector, new object[] { "file.cs", _testRepoPath, null }); - Assert.IsTrue((bool)resultNull); - var resultEmpty = method.Invoke(_detector, new object[] { "file.cs", _testRepoPath, Array.Empty() }); - Assert.IsTrue((bool)resultEmpty); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_RepeatedNoMergeBaseState_LogsOnce() - { - string originalBranch; - using (var repo = new Repository(_testRepoPath)) - { - originalBranch = repo.Head.FriendlyName; - } - - ExecGit("checkout --orphan unrelated-main"); - var unrelatedReadme = Path.Combine(_testRepoPath, "unrelated.md"); - File.WriteAllText(unrelatedReadme, "# Unrelated"); - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, "unrelated.md"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - var unrelatedCommitId = repo.Commit("Unrelated commit", signature, signature).Id; - repo.Refs.Add("refs/remotes/origin/main", unrelatedCommitId); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit($"checkout {originalBranch}"); - - _fakeLogger.ClearDebugMessages(); - - await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var noMergeBaseLogs = _fakeLogger.SnapshotDebugMessages() - .Count(m => m.Contains("No merge base commit found, using working directory changes only")); - - Assert.AreEqual(1, noMergeBaseLogs, "Should log no merge base state only once until repo state changes"); - } - - [TestMethod] - public async Task ClearMainBranchCandidatesCache_ClearsNoMergeBaseLogThrottle() - { - string originalBranch; - using (var repo = new Repository(_testRepoPath)) - { - originalBranch = repo.Head.FriendlyName; - } - - ExecGit("checkout --orphan unrelated-main"); - var unrelatedReadme = Path.Combine(_testRepoPath, "unrelated.md"); - File.WriteAllText(unrelatedReadme, "# Unrelated"); - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, "unrelated.md"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - var unrelatedCommitId = repo.Commit("Unrelated commit", signature, signature).Id; - repo.Refs.Add("refs/remotes/origin/main", unrelatedCommitId); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit($"checkout {originalBranch}"); - - _fakeLogger.ClearDebugMessages(); - - await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - _detector.ClearMainBranchCandidatesCache(); - - await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var noMergeBaseLogs = _fakeLogger.SnapshotDebugMessages() - .Count(m => m.Contains("No merge base commit found, using working directory changes only")); - - Assert.AreEqual(2, noMergeBaseLogs, "Clearing candidate cache should reset no-merge-base log throttle"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_FindsMergeBaseWithMainBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("feature.cs", "public class Feature {}", "Add feature"); - - var changedFiles = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - changedFiles.Any(f => f.EndsWith("feature.cs")), - "Should detect feature.cs as changed vs main branch"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_IteratesToSecondCandidate() - { - using (var repo = new Repository(_testRepoPath)) - { - var developBranch = repo.CreateBranch("develop"); - LibGit2Sharp.Commands.Checkout(repo, developBranch); - } - - try - { - ExecGit($"branch -D master"); - } - catch - { - } - - try - { - ExecGit($"branch -D main"); - } - catch - { - } - - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("feature.cs", "public class Feature {}", "Add feature"); - - var changedFiles = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - changedFiles.Any(f => f.EndsWith("feature.cs")), - "Should find merge base with 'develop' branch when main/master not available"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_HandlesDetachedHead() - { - string commitSha; - using (var repo = new Repository(_testRepoPath)) - { - commitSha = repo.Head.Tip.Sha; - } - - ExecGit($"checkout {commitSha}"); - - var filePath = Path.Combine(_testRepoPath, "detached.cs"); - File.WriteAllText(filePath, "public class Detached {}"); - - var changedFiles = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull( - changedFiles, - "Should return results even in detached HEAD state"); - Assert.IsTrue( - changedFiles.Any(f => f.EndsWith("detached.cs")), - "Should detect new file in detached HEAD state"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NullGitRootPath_ReturnsEmptyList() - { - var result = await _detector.GetChangedFilesVsBaselineAsync( - null, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsEmpty(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_EmptyGitRootPath_ReturnsEmptyList() - { - var result = await _detector.GetChangedFilesVsBaselineAsync( - string.Empty, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsEmpty(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NonExistentDirectory_ReturnsEmptyList() - { - var nonExistentPath = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid()}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - nonExistentPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsEmpty(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NoGitRepository_ReturnsEmptyList() - { - var tempDir = Path.Combine(Path.GetTempPath(), $"no-git-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempDir); - - try - { - var result = await _detector.GetChangedFilesVsBaselineAsync( - tempDir, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsEmpty(result); - } - finally - { - try - { - Directory.Delete(tempDir, true); - } - catch - { - } - } - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_CorruptedRepository_ReturnsEmptyListAndLogsWarning() - { - var gitObjectsPath = Path.Combine(_testRepoPath, ".git", "objects"); - if (Directory.Exists(gitObjectsPath)) - { - try - { - Directory.Delete(gitObjectsPath, true); - } - catch - { - } - } - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsEmpty(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NullSavedFilesTracker_HandledGracefully() - { - CommitFile("test.cs", "public class Test {}", "Add test file"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, null, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NullOpenFilesObserver_HandledGracefully() - { - CommitFile("test.cs", "public class Test {}", "Add test file"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, null, string.Empty); - - Assert.IsNotNull(result); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NoMainBranchCandidates_UsesWorkingDirectoryOnly() - { - using (var repo = new Repository(_testRepoPath)) - { - var currentBranch = repo.Head.FriendlyName; - var featureBranch = repo.CreateBranch("feature-xyz"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - try - { - ExecGit("branch -D master"); - } - catch - { - } - - try - { - ExecGit("branch -D main"); - } - catch - { - } - - var testFilePath = Path.Combine(_testRepoPath, "newfile.cs"); - File.WriteAllText(testFilePath, "public class NewFile {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsTrue( - result.Any(f => f.EndsWith("newfile.cs")), - "Should detect working directory changes when no main branch candidates exist"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_UnsupportedFiles_NotIncluded() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("test.txt", "text file content", "Add text file"); - CommitFile("test.cs", "public class Test {}", "Add cs file"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - result.Any(f => f.EndsWith("test.cs")), - "Should include supported .cs file"); - Assert.IsFalse( - result.Any(f => f.EndsWith("test.txt")), - "Should exclude unsupported .txt file"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_MultipleChangedFiles_ReturnsAll() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("file1.cs", "public class File1 {}", "Add file1"); - CommitFile("file2.js", "function test() {}", "Add file2"); - CommitFile("file3.py", "def test():\n pass", "Add file3"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue(result.Any(f => f.EndsWith("file1.cs"))); - Assert.IsTrue(result.Any(f => f.EndsWith("file2.js"))); - Assert.IsTrue(result.Any(f => f.EndsWith("file3.py"))); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_OnMainBranch_ReturnsOnlyWorkingDirectoryChanges() - { - var testFilePath = Path.Combine(_testRepoPath, "newfile.cs"); - File.WriteAllText(testFilePath, "public class NewFile {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result); - Assert.IsTrue( - result.Any(f => f.EndsWith("newfile.cs")), - "Should detect working directory changes when on main branch"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_DeduplicatesChangedFiles() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - var testFilePath = CommitFile("test.cs", "public class Test {}", "Add test file"); - - File.WriteAllText(testFilePath, "public class Test { /* modified */ }"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var testFileCount = result.Count(f => f.EndsWith("test.cs")); - Assert.AreEqual(1, testFileCount, "Should deduplicate files that appear in both committed and status changes"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_MainBranchCandidateDoesNotExist_ReturnsWorkingDirChanges() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-branch"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - try - { - ExecGit("branch -D master"); - } - catch - { - } - - try - { - ExecGit("branch -D main"); - } - catch - { - } - - var newFile = Path.Combine(_testRepoPath, "test.cs"); - File.WriteAllText(newFile, "public class Test {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotEmpty(result, "Should detect working directory changes"); - Assert.IsTrue(result.Any(f => f.Contains("test.cs")), "Should include new file"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_UnalteredFiles_NotIncluded() - { - CommitFile("unchanged.cs", "public class Unchanged {}", "Add unchanged file"); - - var modifiedFile = Path.Combine(_testRepoPath, "modified.cs"); - File.WriteAllText(modifiedFile, "public class Modified {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - result.Any(f => f.Contains("modified.cs")), - "Should include modified file"); - Assert.IsFalse( - result.Any(f => f.Contains("unchanged.cs")), - "Should NOT include unaltered file"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_IgnoredFiles_NotIncluded() - { - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "*.log\n"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, ".gitignore"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Add gitignore", signature, signature); - } - - var ignoredFile = Path.Combine(_testRepoPath, "test.log"); - File.WriteAllText(ignoredFile, "log content"); - - var normalFile = Path.Combine(_testRepoPath, "test.cs"); - File.WriteAllText(normalFile, "public class Test {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - result.Any(f => f.Contains("test.cs")), - "Should include non-ignored file"); - Assert.IsFalse( - result.Any(f => f.Contains("test.log")), - "Should NOT include ignored file"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_WhenWorkspaceIsSubfolder_ReturnsOnlyWorkspaceFilesAsGitRelativePaths() - { - var workspaceSubdir = Path.Combine(_testRepoPath, "workspace-subdir"); - Directory.CreateDirectory(workspaceSubdir); - - CreateFile("workspace-subdir/inside.cs", "public class Inside {}"); - CreateFile("outside.cs", "public class Outside {}"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, "workspace-subdir/inside.cs"); - LibGit2Sharp.Commands.Stage(repo, "outside.cs"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Add files", signature, signature); - } - - File.WriteAllText(Path.Combine(workspaceSubdir, "inside.cs"), "public class InsideModified {}"); - File.WriteAllText(Path.Combine(_testRepoPath, "outside.cs"), "public class OutsideModified {}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, new[] { workspaceSubdir }, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - result.Any(f => f.Replace('\\', '/').Equals("workspace-subdir/inside.cs")), - "Should include file inside workspace with git-relative path"); - Assert.IsFalse( - result.Any(f => f.Contains("outside")), - "Should not include file outside workspace"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExceptionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExceptionTests.cs deleted file mode 100644 index 0c1a2b4e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExceptionTests.cs +++ /dev/null @@ -1,371 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeDetectorExceptionTests : GitChangeDetectorTestBase - { - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_LibGit2SharpException_ReturnsEmptyAndLogsError() - { - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ThrowInGetChangedFilesFromRepository = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsEmpty(result, "Should return empty list on exception"); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error getting changed files")), - "Should log error message on exception"); - } - - [TestMethod] - public async Task GetMergeBaseCommit_ThrowsException_ReturnsNullAndLogsDebug() - { - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ThrowFromMainBranchCandidates = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsTrue( - _fakeLogger.SnapshotDebugMessages().Any(m => m.Contains("Could not determine merge base")), - "Should log debug message when GetMergeBaseCommit throws"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_InvalidGitRootPath_ReturnsEmptyList() - { - var result = await _detector.GetChangedFilesVsBaselineAsync( - string.Empty, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsEmpty(result, "Should return empty list for invalid git root path"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_NonexistentGitRootPath_ReturnsEmptyList() - { - var result = await _detector.GetChangedFilesVsBaselineAsync( - "C:\\nonexistent\\path", null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsEmpty(result, "Should return empty list for nonexistent git root path"); - } - - [TestMethod] - public void GetMainBranchCandidates_EmptyWorkingDirectory_ReturnsEmptyList() - { - using (var repo = new Repository(_testRepoPath)) - { - var candidates = GetMainBranchCandidates(repo); - - Assert.IsNotNull(candidates, "Should not return null"); - } - } - - [TestMethod] - public void GetMainBranchCandidates_BareRepository_ReturnsEmptyList() - { - var bareRepoPath = Path.Combine(Path.GetTempPath(), $"test-bare-repo-{Guid.NewGuid()}"); - try - { - Repository.Init(bareRepoPath, isBare: true); - - using (var bareRepo = new Repository(bareRepoPath)) - { - var candidates = _detector.GetMainBranchCandidates(bareRepo); - - Assert.IsNotNull(candidates, "Should not return null"); - Assert.IsEmpty(candidates, "Should return empty list for bare repository"); - } - } - finally - { - if (Directory.Exists(bareRepoPath)) - { - try - { - Directory.Delete(bareRepoPath, true); - } - catch - { - } - } - } - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_OrphanedBranch_HandlesGracefully() - { - string originalBranch; - using (var repo = new Repository(_testRepoPath)) - { - originalBranch = repo.Head.FriendlyName; - } - - ExecGit("checkout --orphan orphaned"); - CommitFile("orphan.cs", "// orphan code", "Orphaned commit"); - ExecGit($"checkout {originalBranch}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle orphaned branches gracefully"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_MultipleOrphanedBranches_HandlesGracefully() - { - string originalBranch; - using (var repo = new Repository(_testRepoPath)) - { - originalBranch = repo.Head.FriendlyName; - } - - ExecGit("checkout --orphan orphan1"); - CommitFile("orphan1.cs", "// code", "Orphan 1"); - ExecGit($"checkout {originalBranch}"); - ExecGit("checkout --orphan orphan2"); - CommitFile("orphan2.cs", "// code", "Orphan 2"); - ExecGit($"checkout {originalBranch}"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle multiple orphaned branches gracefully"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_CorruptedDiffScenario_HandlesGracefully() - { - ExecGit("checkout -b feature"); - for (int i = 0; i < 10; i++) - { - CommitFile($"file{i}.cs", $"// content {i}", $"Commit {i}"); - } - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle complex diff scenarios gracefully"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_ManyUnstagedFiles_HandlesGracefully() - { - for (int i = 0; i < 20; i++) - { - File.WriteAllText(Path.Combine(_testRepoPath, $"unstaged{i}.cs"), $"// unstaged {i}"); - } - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle many unstaged files gracefully"); - } - - [TestMethod] - public async Task GetMergeBaseCommit_InvalidCurrentBranch_ReturnsEmptyList() - { - ExecGit("checkout -b feature-test"); - CommitFile("test.cs", "public class Test {}", "Add test"); - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.SimulateInvalidCurrentBranch = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should return non-null result even with invalid branch"); - } - - [TestMethod] - public async Task TryFindMergeBase_InvalidMainBranch_ReturnsEmptyList() - { - ExecGit("checkout -b feature-test"); - CommitFile("test.cs", "public class Test {}", "Add test"); - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.SimulateInvalidMainBranch = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle invalid main branch gracefully"); - } - - [TestMethod] - public async Task TryFindMergeBase_FindMergeBaseThrows_HandlesGracefully() - { - ExecGit("checkout -b feature-test"); - CommitFile("test.cs", "public class Test {}", "Add test"); - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ThrowFromFindMergeBase = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle FindMergeBase exception gracefully"); - Assert.IsTrue( - _fakeLogger.SnapshotDebugMessages().Any(m => m.Contains("Could not determine merge base")), - "Should log debug message when FindMergeBase throws exception"); - } - - [TestMethod] - public async Task GetCommittedChanges_DiffCompareThrows_HandlesGracefully() - { - ExecGit("checkout -b feature-test"); - CommitFile("test.cs", "public class Test {}", "Add test"); - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ThrowFromDiffCompare = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle Diff.Compare exception gracefully"); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error getting changed files")), - "Should log error message when Diff.Compare throws exception"); - } - - [TestMethod] - public async Task GetStatusChanges_RetrieveStatusThrows_HandlesGracefully() - { - ExecGit("checkout -b feature-test"); - CommitFile("test.cs", "public class Test {}", "Add test"); - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ThrowFromRetrieveStatus = true; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle RetrieveStatus exception gracefully"); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error getting changed files")), - "Should log error message when RetrieveStatus throws exception"); - } - - [TestMethod] - public async Task GetMergeBaseCommit_UnbornHead_ReturnsGracefully() - { - var unbornRepoPath = Path.Combine(Path.GetTempPath(), $"test-unborn-{Guid.NewGuid()}"); - try - { - Directory.CreateDirectory(unbornRepoPath); - Repository.Init(unbornRepoPath); - using (var repo = new Repository(unbornRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - var result = await _detector.GetChangedFilesVsBaselineAsync( - unbornRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsEmpty(result, "Should return empty list for unborn HEAD"); - } - finally - { - if (Directory.Exists(unbornRepoPath)) - { - try - { - Directory.Delete(unbornRepoPath, true); - } - catch - { - } - } - } - } - - [TestMethod] - public async Task TryFindMergeBase_BranchNotFound_ReturnsNull() - { - ExecGit("checkout --orphan orphan-feature"); - CommitFile("test.cs", "public class Test {}", "Orphan commit"); - - using (var repo = new Repository(_testRepoPath)) - { - var mainBranches = new[] { "main", "master", "develop" }; - foreach (var branchName in mainBranches) - { - var branch = repo.Branches[branchName]; - if (branch != null) - { - repo.Branches.Remove(branch); - } - } - } - - var testableDetector = new TestableGitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - testableDetector.ForceBranchLookupFailure = "nonexistent-branch"; - - var result = await testableDetector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle non-existent branch gracefully"); - } - - [TestMethod] - public async Task TryFindMergeBase_FindMergeBaseThrowsLibGit2_LogsDebug() - { - ExecGit("checkout -b main"); - CommitFile("main.cs", "public class Main {}", "Main commit"); - ExecGit("checkout -b feature"); - CommitFile("feature.cs", "public class Feature {}", "Feature commit"); - - var packPath = Path.Combine(_testRepoPath, ".git", "objects", "pack"); - if (Directory.Exists(packPath)) - { - foreach (var packFile in Directory.GetFiles(packPath)) - { - try - { - File.Delete(packFile); - } - catch - { - } - } - } - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle corrupted repository gracefully"); - } - - [TestMethod] - public async Task GetCommittedChanges_DiffCompareThrowsLibGit2_LogsDebug() - { - ExecGit("checkout -b main"); - CommitFile("main.cs", "public class Main {}", "Main commit"); - ExecGit("checkout -b feature"); - CommitFile("feature.cs", "public class Feature {}", "Feature commit"); - CorruptGitObjects(); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle corrupted objects gracefully"); - } - - [TestMethod] - public async Task GetStatusChanges_RetrieveStatusThrowsLibGit2_LogsDebug() - { - CommitFile("test.cs", "public class Test {}", "Add test"); - CorruptGitIndex(); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - Assert.IsNotNull(result, "Should handle corrupted index gracefully"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExclusionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExclusionTests.cs deleted file mode 100644 index d93e8734..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExclusionTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeDetectorExclusionTests : GitChangeDetectorTestBase - { - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_ExcludesSavedFiles() - { - var testFilePath = CommitFile("test.cs", "public class Test {}", "Add test file"); - - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("feature.cs", "public class Feature {}", "Add feature"); - - File.WriteAllText(testFilePath, "public class Test { /* modified */ }"); - _fakeSavedFilesTracker.AddSavedFile(testFilePath); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var normalizedResult = result.Select(f => f.Replace('/', '\\')).ToList(); - Assert.IsFalse( - normalizedResult.Any(f => f.EndsWith("test.cs")), - "Should exclude saved file from results"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_ActiveDocumentRemainsInChangedList() - { - var testFilePath = CommitFile("test.cs", "public class Test {}", "Add test file"); - - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - CommitFile("feature.cs", "public class Feature {}", "Add feature"); - - File.WriteAllText(testFilePath, "public class Test { /* modified */ }"); - _fakeOpenFilesObserver.SetActiveDocument(testFilePath); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var normalizedResult = result.Select(f => f.Replace('/', '\\')).ToList(); - Assert.IsTrue( - normalizedResult.Any(f => f.EndsWith("test.cs")), - "Active document must remain in changed list so delta cache is not cleared"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_ActiveDocumentWithCommittedChangesRemainsInList() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-test"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - var committedFilePath = CommitFile("committed-on-feature.cs", "public class Committed {}", "Add on feature"); - _fakeOpenFilesObserver.SetActiveDocument(committedFilePath); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var normalizedResult = result.Select(f => f.Replace('/', '\\')).ToList(); - Assert.IsTrue( - normalizedResult.Any(f => f.EndsWith("committed-on-feature.cs")), - "Active document must remain in changed list so delta cache is not cleared"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_FileInExclusionSet_ExcludedFromStatusChanges() - { - var excludedPath = Path.Combine(_testRepoPath, "excluded.cs"); - File.WriteAllText(excludedPath, "public class Excluded {}"); - _fakeSavedFilesTracker.AddSavedFile(excludedPath); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, string.Empty); - - var normalizedResult = result.Select(f => f.Replace('/', '\\')).ToList(); - Assert.IsFalse( - normalizedResult.Any(f => f.EndsWith("excluded.cs")), - "File in exclusion set should be excluded from status changes"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMainBranchTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMainBranchTests.cs deleted file mode 100644 index a6598d4e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMainBranchTests.cs +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeDetectorMainBranchTests : GitChangeDetectorTestBase - { - [TestMethod] - public void Constructor_InitializesWithLogger() - { - var detector = new GitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - - Assert.IsNotNull(detector); - } - - [TestMethod] - public void GetMainBranchCandidates_DetectsMainBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - var currentBranch = repo.Head.FriendlyName; - var candidates = GetMainBranchCandidates(repo); - - Assert.IsNotEmpty(candidates, "Should detect at least one main branch candidate"); - Assert.IsTrue( - candidates.Contains("main") || candidates.Contains("master"), - "Should detect either 'main' or 'master' as a candidate"); - Assert.Contains( - currentBranch, - candidates, - $"Should detect current branch '{currentBranch}' as a candidate"); - } - } - - [TestMethod] - public void GetMainBranchCandidates_DetectsDevelopBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - var developBranch = repo.CreateBranch("develop"); - } - - using (var repo = new Repository(_testRepoPath)) - { - var candidates = GetMainBranchCandidates(repo); - - Assert.Contains( - "develop", - candidates, - "Should detect 'develop' branch when it exists"); - } - } - - [TestMethod] - public void GetMainBranchCandidates_DetectsAllSupportedBranches() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.CreateBranch("develop"); - repo.CreateBranch("trunk"); - repo.CreateBranch("dev"); - } - - using (var repo = new Repository(_testRepoPath)) - { - var candidates = GetMainBranchCandidates(repo); - - var expectedBranches = MainBranchNames.All; - var currentBranch = repo.Head.FriendlyName; - - Assert.Contains("develop", candidates, "Should detect 'develop'"); - Assert.Contains("trunk", candidates, "Should detect 'trunk'"); - Assert.Contains("dev", candidates, "Should detect 'dev'"); - - foreach (var branch in expectedBranches) - { - if (repo.Branches[branch] != null) - { - Assert.Contains( - branch, - candidates, - $"Should detect existing branch '{branch}'"); - } - } - } - } - - [TestMethod] - public void GetMainBranchCandidates_ExcludesRemoteBranches() - { - ExecGit("remote add origin https://github.com/test/repo.git"); - ExecGit("config remote.origin.fetch +refs/heads/*:refs/remotes/origin/*"); - - using (var repo = new Repository(_testRepoPath)) - { - var currentBranch = repo.Head.FriendlyName; - repo.Refs.Add($"refs/remotes/origin/{currentBranch}", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/develop", repo.Head.Tip.Id); - } - - using (var repo = new Repository(_testRepoPath)) - { - var candidates = GetMainBranchCandidates(repo); - - var remoteBranches = repo.Branches.Where(b => b.IsRemote).Select(b => b.FriendlyName).ToList(); - Assert.IsNotEmpty(remoteBranches, "Should have created remote branches"); - - foreach (var remoteBranch in remoteBranches) - { - Assert.DoesNotContain( - remoteBranch, - candidates, - $"Should not include remote branch '{remoteBranch}'"); - } - } - } - - [TestMethod] - public void GetMainBranchCandidates_ReturnsEmpty_WhenNoMainBranches() - { - using (var repo = new Repository(_testRepoPath)) - { - var currentBranch = repo.Head.FriendlyName; - var featureBranch = repo.CreateBranch("feature-xyz"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - DeleteLocalMainBranches(); - - using (var repo = new Repository(_testRepoPath)) - { - var candidates = GetMainBranchCandidates(repo); - - Assert.IsEmpty( - candidates, - "Should return empty list when no main branch candidates exist"); - } - } - - [TestMethod] - public void ClearMainBranchCandidatesCache_ForcesRecomputation() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.CreateBranch("develop"); - } - - List beforeClear; - List afterClear; - using (var repo = new Repository(_testRepoPath)) - { - beforeClear = GetMainBranchCandidates(repo); - _detector.ClearMainBranchCandidatesCache(_testRepoPath.TrimEnd('\\', '/')); - afterClear = GetMainBranchCandidates(repo); - } - - CollectionAssert.AreEqual(beforeClear, afterClear); - Assert.Contains("develop", afterClear); - } - - [TestMethod] - public void ClearMainBranchCandidatesCache_WhenCacheNull_IsNoOp() - { - _detector.ClearMainBranchCandidatesCache(); - _detector.ClearMainBranchCandidatesCache(_testRepoPath); - } - - [TestMethod] - public void ClearMainBranchCandidatesCache_WithNullPath_ClearsAllEntries() - { - List firstCall; - List secondCall; - - using (var repo = new Repository(_testRepoPath)) - { - firstCall = GetMainBranchCandidates(repo); - } - - _detector.ClearMainBranchCandidatesCache(); - - using (var repo = new Repository(_testRepoPath)) - { - secondCall = GetMainBranchCandidates(repo); - } - - CollectionAssert.AreEqual(firstCall, secondCall); - Assert.IsNotEmpty(secondCall); - } - - [TestMethod] - public void GetMainBranchCandidates_CachesCandidates() - { - List firstCall; - List secondCall; - - using (var repo = new Repository(_testRepoPath)) - { - firstCall = GetMainBranchCandidates(repo); - } - - using (var repo = new Repository(_testRepoPath)) - { - secondCall = GetMainBranchCandidates(repo); - } - - CollectionAssert.AreEqual( - firstCall, - secondCall, - "Subsequent calls should return cached candidates"); - } - - [TestMethod] - public void GetMainBranchCandidates_TrustsDefaultBranch_WithoutLocalBranch() - { - ExecGit("remote add origin https://github.com/test/repo.git"); - ExecGit("config remote.origin.fetch +refs/heads/*:refs/remotes/origin/*"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - } - - ExecGit("remote set-head origin main"); - - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-xyz"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - DeleteLocalMainBranches(); - - using (var repo = new Repository(_testRepoPath)) - { - var detector = new GitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - var candidates = detector.GetMainBranchCandidates(repo); - - Assert.HasCount(1, candidates, "Should return exactly one candidate"); - Assert.AreEqual("main", candidates[0], "Should trust origin/HEAD resolved branch even without local branch"); - } - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_FallsBackToRemoteBranch() - { - ExecGit("remote add origin https://github.com/test/repo.git"); - ExecGit("config remote.origin.fetch +refs/heads/*:refs/remotes/origin/*"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - } - - ExecGit("remote set-head origin main"); - - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-xyz"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - DeleteLocalMainBranches(); - - CommitFile("test.cs", "public class Test {}", "Add test file"); - - var changedFiles = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, - new[] { _testRepoPath }, - _fakeSavedFilesTracker, - _fakeOpenFilesObserver, - string.Empty); - - Assert.Contains( - "test.cs", - changedFiles, - "Should detect changed file using remote branch as baseline"); - } - - private void DeleteLocalMainBranches() - { - foreach (var branchName in MainBranchNames.All) - { - try - { - ExecGit($"branch -D {branchName}"); - } - catch - { - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMergeCommitTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMergeCommitTests.cs deleted file mode 100644 index 2013d2f4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMergeCommitTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeDetectorMergeCommitTests : GitChangeDetectorTestBase - { - private static readonly string[] UpstreamOnlyFiles = { "upstream-a.cs", "upstream-b.cs", "upstream-c.cs" }; - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_ExcludesFilesIntroducedByMergingBaseline() - { - var baseCommit = PrepareRepositoryWithMergedBaseline(); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, baseCommit); - - Assert.IsTrue( - result.Any(f => f.EndsWith("feature.cs")), - $"Should include feature.cs. Found: {string.Join(", ", result)}"); - - foreach (var upstreamFile in UpstreamOnlyFiles) - { - Assert.IsFalse( - result.Any(f => f.EndsWith(upstreamFile)), - $"Should exclude merge-introduced file {upstreamFile}. Found: {string.Join(", ", result)}"); - } - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_IncludesFeatureCommitsMadeAfterMerge() - { - var baseCommit = PrepareRepositoryWithMergedBaseline(); - CommitFile("after-merge.cs", "public class AfterMerge {}", "Feature work after merge"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, baseCommit); - - Assert.IsTrue( - result.Any(f => f.EndsWith("feature.cs")), - $"Should include feature.cs. Found: {string.Join(", ", result)}"); - Assert.IsTrue( - result.Any(f => f.EndsWith("after-merge.cs")), - $"Should include after-merge.cs. Found: {string.Join(", ", result)}"); - Assert.IsFalse( - result.Any(f => f.EndsWith("upstream-a.cs")), - $"Should exclude merge-introduced upstream-a.cs. Found: {string.Join(", ", result)}"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaselineAsync_IncludesFilesModifiedBothOnFeatureAndBaseline() - { - ExecGit("branch -M main"); - CommitFile("shared.cs", "public class Shared {}", "Add shared file"); - - string baseCommit; - using (var repo = new Repository(_testRepoPath)) - { - baseCommit = repo.Head.Tip.Sha; - } - - ExecGit("checkout -b upstream"); - CommitFile("shared.cs", "public class Shared { /* upstream */ }", "Upstream edit"); - - ExecGit("checkout main"); - ExecGit("checkout -b feature"); - CommitFile("feature.cs", "public class Feature {}", "Add feature"); - CommitFile("shared.cs", "public class Shared { /* feature */ }", "Feature edit"); - ExecGit("merge upstream -m \"Merge upstream into feature\" --no-edit -X ours"); - - var result = await _detector.GetChangedFilesVsBaselineAsync( - _testRepoPath, null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, baseCommit); - - Assert.IsTrue( - result.Any(f => f.EndsWith("shared.cs")), - $"Should include shared.cs changed on the feature branch. Found: {string.Join(", ", result)}"); - } - - private string PrepareRepositoryWithMergedBaseline() - { - ExecGit("branch -M main"); - - string baseCommit; - using (var repo = new Repository(_testRepoPath)) - { - baseCommit = repo.Head.Tip.Sha; - } - - ExecGit("checkout -b feature"); - CommitFile("feature.cs", "public class Feature {}", "Add feature file"); - - ExecGit("checkout -b upstream main"); - foreach (var upstreamFile in UpstreamOnlyFiles) - { - CommitFile(upstreamFile, $"public class {Path.GetFileNameWithoutExtension(upstreamFile).Replace("-", "_")} {{}}", $"Add {upstreamFile}"); - } - - ExecGit("checkout feature"); - ExecGit("merge upstream -m \"Merge upstream into feature\""); - - return baseCommit; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorTestBase.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorTestBase.cs deleted file mode 100644 index da72b4fd..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorTestBase.cs +++ /dev/null @@ -1,458 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - public abstract class GitChangeDetectorTestBase - { - protected string _testRepoPath; - protected GitChangeDetector _detector; - protected FakeLogger _fakeLogger; - protected FakeSupportedFileChecker _fakeSupportedFileChecker; - protected FakeSavedFilesTracker _fakeSavedFilesTracker; - protected FakeOpenFilesObserver _fakeOpenFilesObserver; - protected FakeGitService _fakeGitService; - - [TestInitialize] - public void Setup() - { - MainBranchNames.ClearDefaultBranchCache(); - - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-git-detector-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - CommitFile("README.md", "# Test", "Initial commit"); - - _fakeLogger = new FakeLogger(); - _fakeSupportedFileChecker = new FakeSupportedFileChecker(); - _fakeSavedFilesTracker = new FakeSavedFilesTracker(); - _fakeOpenFilesObserver = new FakeOpenFilesObserver(); - _fakeGitService = new FakeGitService(); - - _detector = new GitChangeDetector(_fakeLogger, _fakeSupportedFileChecker, _fakeGitService); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testRepoPath)) - { - try - { - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - protected List GetMainBranchCandidates(Repository repo) - { - return _detector.GetMainBranchCandidates(repo); - } - - protected void CreateFile(string relativePath, string content) - { - var filePath = Path.Combine(_testRepoPath, relativePath); - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir)) - { - Directory.CreateDirectory(dir); - } - - File.WriteAllText(filePath, content); - } - - protected string CommitFile(string filename, string content, string message) - { - var filePath = Path.Combine(_testRepoPath, filename); - File.WriteAllText(filePath, content); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, filename); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit(message, signature, signature); - } - - return filePath; - } - - protected void ExecGit(string args) - { - var psi = new ProcessStartInfo - { - FileName = "git", - Arguments = args, - WorkingDirectory = _testRepoPath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using (var process = Process.Start(psi)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"Git command failed: {args}\n{error}"); - } - } - } - - protected void CorruptGitObjects() - { - var objectsPath = Path.Combine(_testRepoPath, ".git", "objects"); - foreach (var dir in Directory.GetDirectories(objectsPath)) - { - var dirName = Path.GetFileName(dir); - if (dirName.Length != 2) - { - continue; - } - - if (dirName == "pa" || dirName == "in") - { - continue; - } - - try - { - var files = Directory.GetFiles(dir); - if (files.Length > 0) - { - File.Delete(files[0]); - break; - } - } - catch - { - } - } - } - - protected void CorruptGitIndex() - { - var indexPath = Path.Combine(_testRepoPath, ".git", "index"); - if (File.Exists(indexPath)) - { - try - { - using (var fs = File.OpenWrite(indexPath)) - { - fs.SetLength(0); - fs.Write(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, 0, 4); - } - } - catch - { - } - } - } - - protected class FakeLogger : ILogger - { - private readonly List _debugMessages = new List(); - private readonly List _infoMessages = new List(); - private readonly List _warnMessages = new List(); - private readonly List<(string, Exception)> _errorMessages = new List<(string, Exception)>(); - - private readonly object _lock = new object(); - - public void Debug(string message) - { - lock (_lock) - { - _debugMessages.Add(message); - } - } - - public void Info(string message, bool statusBar = false) - { - lock (_lock) - { - _infoMessages.Add(message); - } - } - - public void Warn(string message, bool statusBar = false) - { - lock (_lock) - { - _warnMessages.Add(message); - } - } - - public void Error(string message, Exception ex) - { - lock (_lock) - { - _errorMessages.Add((message, ex)); - } - } - - public List<(string, Exception)> SnapshotErrorMessages() - { - lock (_lock) - { - return new List<(string, Exception)>(_errorMessages); - } - } - - public List SnapshotWarnMessages() - { - lock (_lock) - { - return new List(_warnMessages); - } - } - - public List SnapshotDebugMessages() - { - lock (_lock) - { - return new List(_debugMessages); - } - } - - public List SnapshotInfoMessages() - { - lock (_lock) - { - return new List(_infoMessages); - } - } - - public void ClearErrorMessages() - { - lock (_lock) - { - _errorMessages.Clear(); - } - } - - public void ClearWarnMessages() - { - lock (_lock) - { - _warnMessages.Clear(); - } - } - - public void ClearDebugMessages() - { - lock (_lock) - { - _debugMessages.Clear(); - } - } - } - - protected class FakeSupportedFileChecker : ISupportedFileChecker - { - private readonly Dictionary _supported = new Dictionary(); - - public bool IsSupported(string filePath) - { - if (_supported.ContainsKey(filePath)) - { - return _supported[filePath]; - } - - var extension = Path.GetExtension(filePath)?.ToLower(); - return extension == ".ts" || extension == ".js" || extension == ".py" || extension == ".cs"; - } - - public void SetSupported(string filePath, bool isSupported) - { - _supported[filePath] = isSupported; - } - } - - protected class FakeSavedFilesTracker : ISavedFilesTracker - { - private readonly HashSet _savedFiles = new HashSet(); - - public IEnumerable GetSavedFiles() - { - return _savedFiles; - } - - public void ClearSavedFiles() - { - _savedFiles.Clear(); - } - - public void RemoveFromTracker(string filePath) - { - _savedFiles.Remove(filePath); - } - - public void AddSavedFile(string filePath) - { - _savedFiles.Add(filePath); - } - } - - protected class FakeOpenFilesObserver : IOpenFilesObserver - { - private readonly HashSet _openFiles = new HashSet(); - - public string ActiveDocumentPath { get; private set; } - - public IEnumerable GetAllVisibleFileNames() - { - return _openFiles; - } - - public string GetActiveDocumentPath() - { - return ActiveDocumentPath; - } - - public void AddOpenFile(string filePath) - { - _openFiles.Add(filePath); - } - - public void SetActiveDocument(string filePath) - { - ActiveDocumentPath = filePath; - } - } - - protected class FakeIdeActivityTracker : IIdeActivityTracker - { - private bool _isActive = true; - - public bool IsIdeWindowActive() - { - return _isActive; - } - - public void SetActiveForTesting(bool active) - { - _isActive = active; - } - } - - protected class TestableGitChangeDetector : GitChangeDetector - { - public TestableGitChangeDetector(ILogger logger, ISupportedFileChecker supportedFileChecker, IGitService gitService) - : base(logger, supportedFileChecker, gitService) - { - } - - public bool ThrowInGetChangedFilesFromRepository { get; set; } - - public bool ThrowInGetMergeBaseCommit { get; set; } - - public bool ThrowFromMainBranchCandidates { get; set; } - - public bool ThrowFromFindMergeBase { get; set; } - - public bool ThrowFromDiffCompare { get; set; } - - public bool ThrowFromRetrieveStatus { get; set; } - - public bool SimulateInvalidCurrentBranch { get; set; } - - public bool SimulateInvalidMainBranch { get; set; } - - public string ForceBranchLookupFailure { get; set; } - - public override List GetMainBranchCandidates(Repository repo) - { - if (ThrowFromMainBranchCandidates) - { - throw new Exception("Simulated exception from GetMainBranchCandidates"); - } - - var candidates = base.GetMainBranchCandidates(repo); - if (!string.IsNullOrEmpty(ForceBranchLookupFailure)) - { - candidates.Add(ForceBranchLookupFailure); - } - - return candidates; - } - - protected override (Commit? baseCommit, IReadOnlyList? noMergeBaseCandidates) GetMergeBaseCommit(Repository repo) - { - if (SimulateInvalidCurrentBranch) - { - return (null, null); - } - - if (ThrowInGetMergeBaseCommit) - { - throw new Exception("Simulated exception from GetMergeBaseCommit"); - } - - return base.GetMergeBaseCommit(repo); - } - - protected override List GetChangedFilesFromRepository(Repository repo, ChangeDetectionContext context, string baselineCommit) - { - if (ThrowInGetChangedFilesFromRepository) - { - throw new LibGit2SharpException("Simulated LibGit2Sharp exception"); - } - - return base.GetChangedFilesFromRepository(repo, context, baselineCommit); - } - - protected override Commit? TryFindMergeBase(Repository repo, Branch currentBranch, string candidateName) - { - if (SimulateInvalidMainBranch) - { - return null; - } - - if (ThrowFromFindMergeBase) - { - throw new Exception("Simulated exception from TryFindMergeBase"); - } - - return base.TryFindMergeBase(repo, currentBranch, candidateName); - } - - protected override List GetCommittedChanges(Repository repo, Commit baseCommit, string gitRootPath, IReadOnlyCollection workspacePaths) - { - if (ThrowFromDiffCompare) - { - throw new Exception("Simulated exception from Diff.Compare"); - } - - return base.GetCommittedChanges(repo, baseCommit, gitRootPath, workspacePaths); - } - - protected override List GetStatusChanges(Repository repo, HashSet filesToExclude, string gitRootPath, IReadOnlyCollection workspacePaths) - { - if (ThrowFromRetrieveStatus) - { - throw new Exception("Simulated exception from RetrieveStatus"); - } - - return base.GetStatusChanges(repo, filesToExclude, gitRootPath, workspacePaths); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCoverageTests.cs deleted file mode 100644 index 1300ffdb..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCoverageTests.cs +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeListerCoverageTests : GitChangeDetectorTestBase - { - private Application.Git.GitChangeLister _lister; - - [TestInitialize] - public void SetupLister() - { - _lister = new Application.Git.GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - } - - [TestCleanup] - public void CleanupLister() - { - _lister?.Dispose(); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_NullGitRootPath_ReturnsEmptySet() - { - var result = await _lister.GetAllChangedFilesAsync(null, _testRepoPath); - Assert.IsEmpty(result, "Null git root path should return empty set"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_EmptyGitRootPath_ReturnsEmptySet() - { - var result = await _lister.GetAllChangedFilesAsync(string.Empty, _testRepoPath); - Assert.IsEmpty(result, "Empty git root path should return empty set"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_NonExistentPath_ReturnsEmptySet() - { - var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var result = await _lister.GetAllChangedFilesAsync(nonExistentPath, _testRepoPath); - Assert.IsEmpty(result, "Non-existent path should return empty set"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_NonGitDirectory_ReturnsEmptySet() - { - var nonGitDir = Path.Combine(Path.GetTempPath(), $"non-git-{Guid.NewGuid()}"); - Directory.CreateDirectory(nonGitDir); - try - { - var result = await _lister.GetAllChangedFilesAsync(nonGitDir, nonGitDir); - Assert.IsEmpty(result, "Non-git directory should return empty set"); - } - finally - { - Directory.Delete(nonGitDir, true); - } - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_CorruptedGitRepo_ReturnsEmptySetAndLogsError() - { - CorruptGitIndex(); - _fakeLogger.ClearErrorMessages(); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsEmpty(result, "Corrupted git repo should return empty set"); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Exists(m => m.Item1.Contains("Error")), - "Should log error about corrupted repo"); - } - - [TestMethod] - public async Task CollectFilesFromRepoStateAsync_WithModifiedFiles_ReturnsExpectedFiles() - { - var modifiedFile = Path.Combine(_testRepoPath, "modified.cs"); - CommitFile("modified.cs", "original content", "Add file"); - File.WriteAllText(modifiedFile, "modified content"); - - var result = await _lister.CollectFilesFromRepoStateAsync(_testRepoPath, new[] { _testRepoPath }); - - Assert.HasCount(1, result, "Should return one modified file"); - Assert.Contains(modifiedFile, result, "Should contain the modified file"); - } - - [TestMethod] - public async Task PeriodicScan_WithModifiedFiles_FiresFilesDetectedEvent() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var modifiedFile = Path.Combine(_testRepoPath, "periodic.cs"); - File.WriteAllText(modifiedFile, "new content"); - - HashSet detectedFiles = null; - testableLister.FilesDetected += (sender, files) => detectedFiles = files; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsNotNull(detectedFiles, "FilesDetected event should have fired"); - Assert.Contains(modifiedFile, detectedFiles, "Should contain the modified file"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WithCommittedChangesOnly_FiresFilesDetectedEvent() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - - try - { - ExecGit("checkout -b feature-branch"); - - var committedFile = Path.Combine(_testRepoPath, "committed-feature.cs"); - CommitFile("committed-feature.cs", "feature content", "Add feature file"); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - - HashSet detectedFiles = null; - testableLister.FilesDetected += (sender, files) => detectedFiles = files; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsNotNull(detectedFiles, "FilesDetected event should have fired for committed changes"); - Assert.Contains(committedFile, detectedFiles, "Should detect the committed file on feature branch"); - } - finally - { - testableLister?.Dispose(); - } - } - - [TestMethod] - public async Task PeriodicScan_WithNoFiles_DoesNotFireEvent() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - - bool eventFired = false; - testableLister.FilesDetected += (sender, files) => eventFired = true; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsFalse(eventFired, "FilesDetected event should not fire when no files"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_ExceptionThrown_LogsError() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.ThrowInGetAllChangedFilesAsync = true; - - _fakeLogger.ClearErrorMessages(); - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Exists(m => m.Item1.Contains("Error during periodic scan")), - "Should log error about periodic scan error"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_EmptyInput_ReturnsEmptySet() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - - var result = testableLister.InvokeConvertAndFilterPaths(new List(), _testRepoPath); - - Assert.IsEmpty(result, "Empty input should return empty set"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_RelativePathsConverted_ToAbsolutePaths() - { - CreateFile("file1.cs", "content"); - CreateFile("subdir/file2.cs", "content"); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - var relativePaths = new List { "file1.cs", "subdir/file2.cs" }; - - var result = testableLister.InvokeConvertAndFilterPaths(relativePaths, _testRepoPath); - - Assert.HasCount(2, result, "Should convert all paths"); - Assert.IsTrue(result.All(p => Path.IsPathRooted(p)), "All paths should be absolute"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_FiltersUnsupportedFiles() - { - CreateFile("file1.cs", "content"); - CreateFile("file2.txt", "content"); - CreateFile("file3.md", "content"); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - var relativePaths = new List { "file1.cs", "file2.txt", "file3.md" }; - - var result = testableLister.InvokeConvertAndFilterPaths(relativePaths, _testRepoPath); - - Assert.HasCount(1, result, "Should only include supported files"); - Assert.IsTrue(result.Any(p => p.EndsWith("file1.cs")), "Should include .cs file"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_IncludesOnlySupportedFiles() - { - CreateFile("file1.cs", "content"); - CreateFile("file2.js", "content"); - CreateFile("file3.py", "content"); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - var relativePaths = new List { "file1.cs", "file2.js", "file3.py" }; - - var result = testableLister.InvokeConvertAndFilterPaths(relativePaths, _testRepoPath); - - Assert.HasCount(3, result, "Should include all supported file types"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_MixedSupportedAndUnsupported_FiltersProperly() - { - CreateFile("supported1.cs", "content"); - CreateFile("unsupported1.txt", "content"); - CreateFile("supported2.js", "content"); - CreateFile("unsupported2.md", "content"); - CreateFile("supported3.py", "content"); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - var relativePaths = new List - { - "supported1.cs", - "unsupported1.txt", - "supported2.js", - "unsupported2.md", - "supported3.py", - }; - - var result = testableLister.InvokeConvertAndFilterPaths(relativePaths, _testRepoPath); - - Assert.HasCount(3, result, "Should include only supported files"); - Assert.IsTrue(result.Any(p => p.EndsWith("supported1.cs")), "Should include .cs file"); - Assert.IsTrue(result.Any(p => p.EndsWith("supported2.js")), "Should include .js file"); - Assert.IsTrue(result.Any(p => p.EndsWith("supported3.py")), "Should include .py file"); - Assert.IsFalse(result.Any(p => p.EndsWith(".txt")), "Should not include .txt file"); - Assert.IsFalse(result.Any(p => p.EndsWith(".md")), "Should not include .md file"); - - testableLister.Dispose(); - } - - [TestMethod] - public void ConvertAndFilterPaths_WithSubdirectories_HandlesProperly() - { - CreateFile("root.cs", "content"); - CreateFile("subdir1/file1.cs", "content"); - CreateFile("subdir1/subdir2/file2.js", "content"); - CreateFile("subdir3/unsupported.txt", "content"); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - var relativePaths = new List - { - "root.cs", - "subdir1/file1.cs", - "subdir1/subdir2/file2.js", - "subdir3/unsupported.txt", - }; - - var result = testableLister.InvokeConvertAndFilterPaths(relativePaths, _testRepoPath); - - Assert.HasCount(3, result, "Should include supported files from all directories"); - Assert.IsTrue(result.Any(p => p.EndsWith("root.cs")), "Should include root file"); - Assert.IsTrue(result.Any(p => p.Contains("subdir1") && p.EndsWith("file1.cs")), "Should include subdir1 file"); - Assert.IsTrue(result.Any(p => p.Contains("subdir2") && p.EndsWith("file2.js")), "Should include nested subdir file"); - - testableLister.Dispose(); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCpuThrottlingTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCpuThrottlingTests.cs deleted file mode 100644 index e0be3bf0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCpuThrottlingTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces.Util; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeListerCpuThrottlingTests : GitChangeDetectorTestBase - { - [TestMethod] - public async Task PeriodicScan_WhenCpuTooBusy_SkipsRun() - { - var cpuChecker = new FakeCpuUsageChecker { IsBusy = true }; - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5, - cpuUsageChecker: cpuChecker); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - var filesDetected = new HashSet(); - testableLister.FilesDetected += (sender, files) => filesDetected = files; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsEmpty(filesDetected, "Should not detect files when CPU is too busy"); - var infoMessages = _fakeLogger.SnapshotInfoMessages(); - Assert.IsTrue( - infoMessages.Exists(m => m.Contains("CPU usage too high")), - "Should log that scan was skipped due to high CPU"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenCpuNotBusy_ProcessesFiles() - { - var cpuChecker = new FakeCpuUsageChecker { IsBusy = false }; - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5, - cpuUsageChecker: cpuChecker); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - var filesDetected = new HashSet(); - testableLister.FilesDetected += (sender, files) => filesDetected = files; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsNotEmpty(filesDetected, "Should detect files when CPU is not busy"); - Assert.IsTrue(filesDetected.Any(f => f.Contains("test.cs")), "Should contain the test file"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WithNullCpuChecker_ProcessesFiles() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5, - cpuUsageChecker: null); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - var filesDetected = new HashSet(); - testableLister.FilesDetected += (sender, files) => filesDetected = files; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.IsNotEmpty(filesDetected, "Should detect files when no CPU checker is provided"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_CpuCheckHappensAfterOtherChecks() - { - var cpuChecker = new FakeCpuUsageChecker { IsBusy = true }; - var ideActivityTracker = new FakeIdeActivityTracker(); - ideActivityTracker.SetActiveForTesting(false); - - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5, - ideActivityTracker: ideActivityTracker, - cpuUsageChecker: cpuChecker); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - await testableLister.InvokePeriodicScanAsync(); - - Assert.AreEqual(0, cpuChecker.CheckCount, "CPU checker should not be called if IDE is not active (earlier check fails first)"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenCpuBusyThenNotBusy_SecondRunProcessesFiles() - { - var cpuChecker = new FakeCpuUsageChecker { IsBusy = true }; - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5, - cpuUsageChecker: cpuChecker); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - var filesDetected = new HashSet(); - testableLister.FilesDetected += (sender, files) => filesDetected = files; - - await testableLister.InvokePeriodicScanAsync(); - Assert.IsEmpty(filesDetected, "First run should be skipped due to high CPU"); - - cpuChecker.IsBusy = false; - filesDetected.Clear(); - - await testableLister.InvokePeriodicScanAsync(); - Assert.IsNotEmpty(filesDetected, "Second run should process files when CPU is available"); - - testableLister.Dispose(); - } - - private class FakeCpuUsageChecker : ICpuUsageChecker - { - public bool IsBusy { get; set; } - - public int CheckCount { get; private set; } - - public Task IsCpuTooBusyAsync() - { - CheckCount++; - return Task.FromResult(IsBusy); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerDynamicPeriodTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerDynamicPeriodTests.cs deleted file mode 100644 index deab7c6c..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerDynamicPeriodTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeListerDynamicPeriodTests : GitChangeDetectorTestBase - { - [TestMethod] - public async Task PeriodicScan_WhenSlowExecution_IncreasesPeriod() - { - var basePeriodSeconds = 1; - var simulatedDuration = TimeSpan.FromSeconds(2); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: basePeriodSeconds); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - var executor = testableLister.GetScheduledExecutorForTesting(); - Assert.IsNotNull(executor, "Scheduled executor should exist after StartPeriodicScanning"); - var initialPeriod = executor.GetInterval(); - Assert.AreEqual(TimeSpan.FromSeconds(basePeriodSeconds), initialPeriod); - - CreateFile("test.cs", "content"); - testableLister.SimulatedScanDuration = simulatedDuration; - - await testableLister.InvokePeriodicScanAsync(); - - var newPeriod = executor.GetInterval(); - var minExpectedPeriod = (basePeriodSeconds * 2) + simulatedDuration.TotalSeconds; - Assert.IsGreaterThan( - initialPeriod.TotalSeconds, - newPeriod.TotalSeconds, - $"Period should be increased from {initialPeriod.TotalSeconds}s, got {newPeriod.TotalSeconds}s"); - Assert.IsGreaterThanOrEqualTo( - minExpectedPeriod, - newPeriod.TotalSeconds, - $"Period should be at least {minExpectedPeriod}s, got {newPeriod.TotalSeconds}s"); - - var infoMessages = _fakeLogger.SnapshotDebugMessages(); - Assert.IsTrue( - infoMessages.Exists(m => m.Contains("completed in")), - "Should log completion time"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenPeriodAlreadyHigher_DoesNotDecrease() - { - var basePeriodSeconds = 1; - var simulatedDuration = TimeSpan.FromSeconds(2); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: basePeriodSeconds); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - var executor = testableLister.GetScheduledExecutorForTesting(); - Assert.IsNotNull(executor); - - var highPeriod = TimeSpan.FromSeconds(50); - executor.SetInterval(highPeriod); - Assert.AreEqual(highPeriod, executor.GetInterval()); - - CreateFile("test.cs", "content"); - testableLister.SimulatedScanDuration = simulatedDuration; - - await testableLister.InvokePeriodicScanAsync(); - - Assert.AreEqual(highPeriod, executor.GetInterval(), "Period should not decrease"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenFastExecution_KeepsBasePeriod() - { - var basePeriodSeconds = 5; - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: basePeriodSeconds); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - var executor = testableLister.GetScheduledExecutorForTesting(); - Assert.IsNotNull(executor); - var initialPeriod = executor.GetInterval(); - Assert.AreEqual(TimeSpan.FromSeconds(basePeriodSeconds), initialPeriod); - - CreateFile("test.cs", "content"); - - await testableLister.InvokePeriodicScanAsync(); - - Assert.AreEqual(initialPeriod, executor.GetInterval(), "Period should remain unchanged for fast execution"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenExecutorNotStarted_DoesNotThrow() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 1); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - - CreateFile("test.cs", "content"); - testableLister.SimulatedScanDuration = TimeSpan.FromSeconds(2); - - await testableLister.InvokePeriodicScanAsync(); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_LogsCompletionTime() - { - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: 5); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - - await testableLister.InvokePeriodicScanAsync(); - - var debugMessages = _fakeLogger.SnapshotDebugMessages(); - Assert.IsTrue( - debugMessages.Exists(m => m.Contains("completed in") && m.Contains("s")), - "Should log completion time in debug messages"); - - testableLister.Dispose(); - } - - [TestMethod] - public async Task PeriodicScan_WhenExceedsThreshold_LogsIncreaseMessage() - { - var basePeriodSeconds = 1; - var simulatedDuration = TimeSpan.FromSeconds(2); - var testableLister = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - pollingInterval: basePeriodSeconds); - - testableLister.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableLister.StartPeriodicScanning(CancellationToken.None); - - CreateFile("test.cs", "content"); - testableLister.SimulatedScanDuration = simulatedDuration; - - await testableLister.InvokePeriodicScanAsync(); - - var infoMessages = _fakeLogger.SnapshotInfoMessages(); - Assert.IsTrue( - infoMessages.Exists(m => m.Contains("increased period to")), - "Should log when period is increased"); - - testableLister.Dispose(); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerEdgeCasesTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerEdgeCasesTests.cs deleted file mode 100644 index 7041fcb6..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerEdgeCasesTests.cs +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; -using WebComponentFile = Codescene.VSExtension.Core.Models.WebComponent.Data.File; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeListerEdgeCasesTests : GitChangeDetectorTestBase - { - private GitChangeLister _lister; - - [TestInitialize] - public void SetupLister() - { - _lister = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - } - - [TestCleanup] - public void CleanupLister() - { - _lister?.Dispose(); - DeltaJobTracker.Clear(); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_WithNullPath_ReturnsEmptySet() - { - var result = await _lister.GetAllChangedFilesAsync(null, _testRepoPath); - - Assert.IsEmpty(result, "Should return empty set for null path"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_WithEmptyPath_ReturnsEmptySet() - { - var result = await _lister.GetAllChangedFilesAsync(string.Empty, _testRepoPath); - - Assert.IsEmpty(result, "Should return empty set for empty path"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_WithNonExistentPath_ReturnsEmptySet() - { - var nonExistentPath = Path.Combine(Path.GetTempPath(), $"non-existent-{Guid.NewGuid()}"); - - var result = await _lister.GetAllChangedFilesAsync(nonExistentPath, _testRepoPath); - - Assert.IsEmpty(result, "Should return empty set for non-existent path"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_WithNonGitDirectory_ReturnsEmptySet() - { - var nonGitPath = Path.Combine(Path.GetTempPath(), $"non-git-{Guid.NewGuid()}"); - Directory.CreateDirectory(nonGitPath); - - try - { - var result = await _lister.GetAllChangedFilesAsync(nonGitPath, nonGitPath); - - Assert.IsEmpty(result, "Should return empty set for non-git directory"); - } - finally - { - try - { - Directory.Delete(nonGitPath, true); - } - catch - { - } - } - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_WithCorruptedGitIndex_HandlesGracefully() - { - CorruptGitIndex(); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsNotNull(result, "Should return a result even with corrupted git index"); - } - - [TestMethod] - public async Task CollectFilesFromRepoStateAsync_DetectsModifiedFiles() - { - var modifiedFile = Path.Combine(_testRepoPath, "modified-state.cs"); - CommitFile("modified-state.cs", "original", "Add file"); - File.WriteAllText(modifiedFile, "modified"); - - var result = await _lister.CollectFilesFromRepoStateAsync(_testRepoPath, new[] { _testRepoPath }); - - Assert.HasCount(1, result, "Should detect one modified file"); - Assert.Contains(modifiedFile, result, "Should contain the modified file"); - } - - [TestMethod] - public async Task PeriodicScanAsync_DetectsChangesAndFiresEvent() - { - var testableInstance = new TestableGitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var newFile = Path.Combine(_testRepoPath, "periodic.cs"); - File.WriteAllText(newFile, "content"); - - HashSet detectedFiles = null; - testableInstance.FilesDetected += (sender, files) => detectedFiles = files; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsNotNull(detectedFiles, "Should fire event when files detected"); - Assert.Contains(newFile, detectedFiles, "Should detect the new file"); - } - finally - { - testableInstance?.Dispose(); - } - } - - [TestMethod] - public async Task PeriodicScanAsync_WithNoChanges_DoesNotFireEvent() - { - var testableInstance = new TestableGitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var eventFired = false; - testableInstance.FilesDetected += (sender, files) => eventFired = true; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsFalse(eventFired, "Should not fire event when no files detected"); - } - finally - { - testableInstance?.Dispose(); - } - } - - [TestMethod] - public async Task PeriodicScanAsync_WhenExceptionThrown_LogsError() - { - var testableInstance = new TestableGitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - testableInstance.ThrowInGetAllChangedFilesAsync = true; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsTrue(_fakeLogger.SnapshotErrorMessages().Any(m => m.Item1.Contains("Error during periodic scan")), "Should log error on exception"); - } - finally - { - testableInstance?.Dispose(); - } - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_WithCorruptedRepo_HandlesGracefully() - { - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "feature content", "Add feature"); - - CorruptGitObjects(); - - var result = await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - - Assert.IsNotNull(result, "Should return a result even with corrupted repo"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_OnFeatureBranchWithCorruptedMainBranch_HandlesGracefully() - { - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "feature content", "Add feature"); - - CorruptGitObjects(); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsNotNull(result, "Should handle corrupted main branch gracefully"); - } - - [TestMethod] - public async Task PeriodicScanAsync_SkipsWhenIdeWindowNotFocused() - { - var activityTracker = new FakeIdeActivityTracker(); - activityTracker.SetActiveForTesting(false); - - var testableInstance = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - activityTracker); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var newFile = Path.Combine(_testRepoPath, "should-not-scan.cs"); - File.WriteAllText(newFile, "content"); - - var eventFired = false; - testableInstance.FilesDetected += (sender, files) => eventFired = true; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsFalse(eventFired, "Should not fire event when IDE window not focused"); - } - finally - { - testableInstance?.Dispose(); - } - } - - [TestMethod] - public async Task PeriodicScanAsync_ProceedsWhenIdeWindowFocused() - { - var activityTracker = new FakeIdeActivityTracker(); - activityTracker.SetActiveForTesting(true); - - var testableInstance = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - activityTracker); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var newFile = Path.Combine(_testRepoPath, "should-scan.cs"); - File.WriteAllText(newFile, "content"); - - HashSet detectedFiles = null; - testableInstance.FilesDetected += (sender, files) => detectedFiles = files; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsNotNull(detectedFiles, "Should fire event when IDE window is focused"); - Assert.Contains(newFile, detectedFiles, "Should detect the new file"); - } - finally - { - testableInstance?.Dispose(); - } - } - - [TestMethod] - [DataRow(0, true, DisplayName = "proceeds when no analysis is running")] - [DataRow(1, false, DisplayName = "skips when analysis is running")] - public async Task PeriodicScanAsync_WhenAnalysisRunning_SkipsOrProceeds(int runningJobCount, bool expectEvent) - { - var jobs = new List(); - for (int i = 0; i < runningJobCount; i++) - { - var job = new Job { Type = "deltaAnalysis", State = "running", File = new WebComponentFile { FileName = $"test{i}.cs" } }; - DeltaJobTracker.Add(job); - jobs.Add(job); - } - - var activityTracker = new FakeIdeActivityTracker(); - activityTracker.SetActiveForTesting(true); - - var testableInstance = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService, - activityTracker); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var newFile = Path.Combine(_testRepoPath, "analysis-check.cs"); - System.IO.File.WriteAllText(newFile, "content"); - - var eventFired = false; - testableInstance.FilesDetected += (sender, files) => eventFired = true; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.AreEqual(expectEvent, eventFired); - } - finally - { - testableInstance?.Dispose(); - foreach (var job in jobs) - { - DeltaJobTracker.Remove(job); - } - } - } - - [TestMethod] - public async Task PeriodicScanAsync_ProceedsEvenWhenOnDefaultBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - var currentBranch = repo.Head.FriendlyName; - repo.Refs.Add($"refs/remotes/origin/{currentBranch}", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs[$"refs/remotes/origin/{currentBranch}"]); - } - - var testableInstance = new TestableGitChangeLister( - _fakeSavedFilesTracker, - _fakeSupportedFileChecker, - _fakeLogger, - _fakeGitService); - - try - { - testableInstance.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var newFile = Path.Combine(_testRepoPath, "should-scan-on-main.cs"); - File.WriteAllText(newFile, "content"); - - var eventFired = false; - testableInstance.FilesDetected += (sender, files) => eventFired = true; - - await testableInstance.InvokePeriodicScanAsync(); - - Assert.IsTrue(eventFired, "Should fire event even when on default branch"); - } - finally - { - testableInstance?.Dispose(); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerTests.cs deleted file mode 100644 index 9502c393..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerTests.cs +++ /dev/null @@ -1,520 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeListerTests : GitChangeDetectorTestBase - { - private GitChangeLister _lister; - - [TestInitialize] - public void SetupLister() - { - _lister = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, _fakeGitService); - } - - [TestCleanup] - public void CleanupLister() - { - _lister?.Dispose(); - } - - [TestMethod] - public void SetWorkspacePaths_UpdatesPaths() - { - _lister.SetWorkspacePaths(new[] { _testRepoPath }); - _lister.SetWorkspacePaths(null); - _lister.SetWorkspacePaths(Array.Empty()); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_CleanRepository_ReturnsEmptySet() - { - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsEmpty(result, "Clean repository should return no changed files"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_DetectsNewUntrackedFiles() - { - var newFile = Path.Combine(_testRepoPath, "new-file.cs"); - File.WriteAllText(newFile, "public class NewClass { }"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should detect one new untracked file"); - Assert.Contains(newFile, result, "Should contain the new file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_DetectsModifiedFiles() - { - var existingFile = Path.Combine(_testRepoPath, "existing.cs"); - CommitFile("existing.cs", "original content", "Add existing file"); - - File.WriteAllText(existingFile, "modified content"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should detect one modified file"); - Assert.Contains(existingFile, result, "Should contain the modified file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_DetectsStagedFiles() - { - var stagedFile = Path.Combine(_testRepoPath, "staged.cs"); - File.WriteAllText(stagedFile, "staged content"); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "staged.cs"); - } - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should detect one staged file"); - Assert.Contains(stagedFile, result, "Should contain the staged file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_FiltersUnsupportedFileTypes() - { - var supportedFile = Path.Combine(_testRepoPath, "supported.cs"); - var unsupportedFile = Path.Combine(_testRepoPath, "unsupported.txt"); - - File.WriteAllText(supportedFile, "code"); - File.WriteAllText(unsupportedFile, "text"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should only detect supported file types"); - Assert.Contains(supportedFile, result, "Should contain the .cs file"); - Assert.DoesNotContain(unsupportedFile, result, "Should not contain the .txt file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_DetectsRenamedFiles() - { - CommitFile("original.cs", "content", "Add original file"); - - ExecGit("mv original.cs renamed.cs"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsNotEmpty(result, "Should detect renamed files"); - Assert.IsTrue(result.Any(f => f.Contains("renamed.cs")), "Should detect the new name"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_CombinesStatusAndDiffChanges() - { - ExecGit("checkout -b feature-branch"); - - var committedFile = Path.Combine(_testRepoPath, "committed.cs"); - CommitFile("committed.cs", "committed content", "Add committed file"); - - var modifiedFile = Path.Combine(_testRepoPath, "modified.cs"); - File.WriteAllText(modifiedFile, "modified content"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsGreaterThanOrEqualTo(result.Count, 2, "Should detect both committed and modified files"); - Assert.Contains(committedFile, result, "Should contain the committed file"); - Assert.Contains(modifiedFile, result, "Should contain the modified file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_HandlesFilesWithWhitespaceInNames() - { - var fileWithSpaces = Path.Combine(_testRepoPath, "file with spaces.cs"); - File.WriteAllText(fileWithSpaces, "content"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should handle files with whitespace"); - Assert.Contains(fileWithSpaces, result, "Should contain the file with spaces"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_SkipsDirectoryWithTooManyUntrackedFiles() - { - var subdir = Path.Combine(_testRepoPath, "many-files"); - Directory.CreateDirectory(subdir); - - for (int i = 0; i < 10; i++) - { - File.WriteAllText(Path.Combine(subdir, $"file{i}.cs"), $"content {i}"); - } - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.IsEmpty(result, "Should skip directory with more than 5 untracked files"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_IncludesUntrackedFilesUpToLimit() - { - var subdir = Path.Combine(_testRepoPath, "few-files"); - Directory.CreateDirectory(subdir); - - for (int i = 0; i < 3; i++) - { - File.WriteAllText(Path.Combine(subdir, $"file{i}.cs"), $"content {i}"); - } - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(3, result, "Should include all files when under the limit"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_IncludesSavedFilesEvenWhenHeuristicExceeded() - { - var subdir = Path.Combine(_testRepoPath, "too-many-files"); - Directory.CreateDirectory(subdir); - - var savedFile = Path.Combine(subdir, "saved-file.cs"); - File.WriteAllText(savedFile, "saved content"); - _fakeSavedFilesTracker.AddSavedFile(savedFile); - - for (int i = 0; i < 10; i++) - { - File.WriteAllText(Path.Combine(subdir, $"file{i}.cs"), $"content {i}"); - } - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should include saved file even when heuristic exceeded"); - Assert.Contains(savedFile, result, "Should contain the saved file"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_ReturnsAbsolutePaths() - { - var newFile = Path.Combine(_testRepoPath, "test.cs"); - File.WriteAllText(newFile, "content"); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(1, result, "Should detect the file"); - var filePath = result.First(); - Assert.IsTrue(Path.IsPathRooted(filePath), "Should return absolute path"); - Assert.AreEqual(newFile, filePath, "Absolute path should match expected"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_ExcludesNonExistentFiles() - { - var tempFile = Path.Combine(_testRepoPath, "deleted.cs"); - File.WriteAllText(tempFile, "content"); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "deleted.cs"); - } - - File.Delete(tempFile); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.DoesNotContain(tempFile, result, "Should exclude non-existent files"); - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_ExcludesDeletedFiles() - { - CommitFile("to-delete.cs", "content", "Add file"); - - var filePath = Path.Combine(_testRepoPath, "to-delete.cs"); - File.Delete(filePath); - - var result = await _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.DoesNotContain(filePath, result, "Should exclude deleted files"); - } - - [TestMethod] - public void StartPeriodicScanning_WhenAlreadyStarted_LogsWarningAndReturns() - { - _lister.Initialize(_testRepoPath, new[] { _testRepoPath }); - _lister.StartPeriodicScanning(CancellationToken.None); - - _fakeLogger.ClearWarnMessages(); - _lister.StartPeriodicScanning(CancellationToken.None); - - var warnings = _fakeLogger.SnapshotWarnMessages(); - Assert.HasCount(1, warnings, "Should log warning when already started"); - Assert.IsTrue(warnings.Any(m => m.Contains("already started")), "Warning should mention already started"); - } - - [TestMethod] - public void StopPeriodicScanning_WhenNotStarted_DoesNotThrow() - { - _lister.StopPeriodicScanning(); - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_OnFeatureBranch_ReturnsCommittedChanges() - { - ExecGit("checkout -b feature-branch"); - - var committedFile = Path.Combine(_testRepoPath, "feature-file.cs"); - CommitFile("feature-file.cs", "feature content", "Add feature file"); - - var result = await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - - Assert.IsTrue(result.Any(f => f.Contains("feature-file.cs")), "Should detect committed changes on feature branch"); - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_OnMainBranch_ReturnsEmptySet() - { - var result = await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - - Assert.IsEmpty(result, "Should return empty set on main branch"); - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_RepeatedNoMergeBaseState_LogsWarnOnce() - { - CommitFile("README.md", "# Master", "Initial on master"); - ExecGit("branch -m master"); - CommitFile("master-only.cs", "class M {}", "Master commit"); - - ExecGit("checkout --orphan main"); - var mainFile = Path.Combine(_testRepoPath, "main.md"); - File.WriteAllText(mainFile, "# Main"); - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "main.md"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Initial on main", signature, signature); - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout master"); - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - _fakeLogger.ClearWarnMessages(); - - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - - var warnCount = _fakeLogger.SnapshotWarnMessages() - .Count(m => m.Contains("can't determine merge-base")); - - Assert.AreEqual(1, warnCount, "Should warn about missing merge base only once until repo state changes"); - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_ClearsWarnStateWhenMergeBaseFound() - { - CommitFile("README.md", "# Master", "Initial on master"); - ExecGit("branch -m master"); - CommitFile("master-only.cs", "class M {}", "Master commit"); - - ExecGit("checkout --orphan main"); - var mainFile = Path.Combine(_testRepoPath, "main.md"); - File.WriteAllText(mainFile, "# Main"); - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "main.md"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Initial on main", signature, signature); - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout master"); - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - _fakeLogger.ClearWarnMessages(); - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - Assert.HasCount(1, _fakeLogger.SnapshotWarnMessages(), "Should warn when default baseline has no merge base"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Remove("refs/remotes/origin/HEAD"); - } - - MainBranchNames.ClearDefaultBranchCache(_testRepoPath); - - _fakeLogger.ClearWarnMessages(); - var withFallbackMergeBase = await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - Assert.IsNotEmpty(withFallbackMergeBase, "Should find merge base via fallback when origin/HEAD is unset"); - Assert.IsEmpty(_fakeLogger.SnapshotWarnMessages(), "Should not warn when merge base is found"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - MainBranchNames.ClearDefaultBranchCache(_testRepoPath); - - _fakeLogger.ClearWarnMessages(); - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - Assert.HasCount(1, _fakeLogger.SnapshotWarnMessages(), "Warn throttle should reset after a successful merge-base lookup"); - } - - [TestMethod] - public async Task GetChangedFilesVsMergeBaseAsync_WithUnbornFeatureHead_DoesNotWarn() - { - ExecGit("checkout --orphan feature-unborn"); - - _fakeLogger.ClearWarnMessages(); - await _lister.GetChangedFilesVsMergeBaseAsync(_testRepoPath, _testRepoPath); - - Assert.IsEmpty( - _fakeLogger.SnapshotWarnMessages(), - "Unborn feature branch should not emit merge-base warning"); - } - - [TestMethod] - public void Initialize_SetsGitRootAndWorkspacePath() - { - _lister.Initialize(_testRepoPath, new[] { _testRepoPath }); - - var fileInRepo = Path.Combine(_testRepoPath, "initialized.cs"); - File.WriteAllText(fileInRepo, "content"); - - var result = _lister.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath).Result; - - Assert.IsNotEmpty(result, "Should be able to use lister after initialization"); - } - - [TestMethod] - public void Dispose_MultipleCalls_DoesNotThrow() - { - _lister.Dispose(); - _lister.Dispose(); - } - - [TestMethod] - public void Dispose_StopsPeriodicScanning() - { - _lister.Initialize(_testRepoPath, new[] { _testRepoPath }); - _lister.StartPeriodicScanning(CancellationToken.None); - - _lister.Dispose(); - - _lister.StartPeriodicScanning(CancellationToken.None); - } - - [TestMethod] - public async Task CollectFilesFromRepoStateAsync_UntrackedIgnoredFilesExcluded() - { - var trackedFile = Path.Combine(_testRepoPath, "tracked.cs"); - var ignoredFile = Path.Combine(_testRepoPath, "secret.ignored"); - CommitFile("tracked.cs", "original", "Add tracked file"); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "*.ignored\n"); - - File.WriteAllText(trackedFile, "modified content"); - File.WriteAllText(ignoredFile, "untracked ignored content"); - - var gitServiceWithIgnore = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - using (var listerWithIgnore = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, gitServiceWithIgnore)) - { - var result = await listerWithIgnore.CollectFilesFromRepoStateAsync(_testRepoPath, new[] { _testRepoPath }); - - Assert.HasCount(1, result, "Should only include the non-ignored file"); - Assert.Contains(trackedFile, result, "Should contain the non-ignored file"); - Assert.DoesNotContain(ignoredFile, result, "Should not contain the untracked ignored file"); - } - } - - [TestMethod] - public async Task CollectFilesFromRepoStateAsync_TrackedGitignoredFilesIncluded() - { - var trackedFile = Path.Combine(_testRepoPath, "tracked.cs"); - var trackedIgnoredFile = Path.Combine(_testRepoPath, "force-added.cs"); - CommitFile("tracked.cs", "original", "Add tracked file"); - CommitFile("force-added.cs", "original", "Add file that will match gitignore"); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "force-added.cs\n"); - - File.WriteAllText(trackedFile, "modified content"); - File.WriteAllText(trackedIgnoredFile, "modified content"); - - var gitServiceWithIgnore = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - using (var listerWithIgnore = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, gitServiceWithIgnore)) - { - var result = await listerWithIgnore.CollectFilesFromRepoStateAsync(_testRepoPath, new[] { _testRepoPath }); - - Assert.HasCount(2, result, "Tracked files should be included even when they match gitignore"); - Assert.Contains(trackedFile, result, "Should contain the non-ignored file"); - Assert.Contains(trackedIgnoredFile, result, "Should contain the tracked file that matches gitignore"); - } - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_IgnoredFilesDoNotCountTowardThreshold() - { - var subdir = Path.Combine(_testRepoPath, "threshold-test"); - Directory.CreateDirectory(subdir); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "threshold-test/ignored*.cs\n"); - - for (int i = 0; i < 3; i++) - { - File.WriteAllText(Path.Combine(subdir, $"ignored{i}.cs"), $"ignored {i}"); - } - - for (int i = 0; i < 4; i++) - { - File.WriteAllText(Path.Combine(subdir, $"tracked{i}.cs"), $"tracked {i}"); - } - - var gitServiceWithIgnore = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - using (var listerWithIgnore = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, gitServiceWithIgnore)) - { - var result = await listerWithIgnore.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(4, result, "Should include all 4 non-ignored files (under threshold of 5)"); - Assert.IsTrue(result.All(f => f.Contains("tracked")), "All returned files should be non-ignored tracked files"); - Assert.IsFalse(result.Any(f => f.Contains("ignored")), "No ignored files should be returned"); - } - } - - [TestMethod] - public async Task GetAllChangedFilesAsync_IgnoredFilesExcludedFromThresholdCalculation_AllowsMoreFiles() - { - var subdir = Path.Combine(_testRepoPath, "threshold-calc"); - Directory.CreateDirectory(subdir); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "threshold-calc/ignored*.cs\n"); - - for (int i = 0; i < 5; i++) - { - File.WriteAllText(Path.Combine(subdir, $"ignored{i}.cs"), $"ignored {i}"); - } - - for (int i = 0; i < 5; i++) - { - File.WriteAllText(Path.Combine(subdir, $"real{i}.cs"), $"real {i}"); - } - - var gitServiceWithIgnore = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - using (var listerWithIgnore = new GitChangeLister(_fakeSavedFilesTracker, _fakeSupportedFileChecker, _fakeLogger, gitServiceWithIgnore)) - { - var result = await listerWithIgnore.GetAllChangedFilesAsync(_testRepoPath, _testRepoPath); - - Assert.HasCount(5, result, "Should include all 5 non-ignored files (exactly at threshold)"); - Assert.IsTrue(result.All(f => f.Contains("real")), "All returned files should be non-ignored files"); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTestHelpers.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTestHelpers.cs deleted file mode 100644 index cc9de04e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTestHelpers.cs +++ /dev/null @@ -1,572 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cli.Delta; - -namespace Codescene.VSExtension.Core.Tests -{ - public class TestFileData - { - public TestFileData(string filename, string content, string? commitMessage = null) - { - Filename = filename; - Content = content; - CommitMessage = commitMessage; - } - - public string Filename { get; set; } - - public string Content { get; set; } - - public string? CommitMessage { get; set; } - } - - public class FileAssertionHelper - { - private readonly List _changedFiles; - private readonly TrackerManager _trackerManager; - - public FileAssertionHelper(List changedFiles, TrackerManager trackerManager) - { - _changedFiles = changedFiles; - _trackerManager = trackerManager; - } - - public void AssertInChangedList(TestFileData fileData, bool shouldExist = true) - { - AssertInChangedList(fileData.Filename, shouldExist); - } - - public void AssertInChangedList(string filename, bool shouldExist = true) - { - var exists = _changedFiles.Any(f => f.EndsWith(filename, StringComparison.OrdinalIgnoreCase)); - Assert.AreEqual(shouldExist, exists, shouldExist ? $"Should include {filename}" : $"Should not include {filename}"); - } - - public void AssertInTracker(string filePath, bool shouldExist = true) - { - var exists = _trackerManager.Contains(filePath); - Assert.AreEqual(shouldExist, exists, shouldExist ? "File should be in tracker" : "File should not be in tracker"); - } - } - - public class FakeAsyncTaskScheduler : IAsyncTaskScheduler - { - public void Schedule(Func asyncWork) - { - asyncWork().GetAwaiter().GetResult(); - } - - public void Schedule(Func asyncWork) - { - asyncWork(CancellationToken.None).GetAwaiter().GetResult(); - } - - public void Schedule(Func asyncWork, CancellationToken cancellationToken) - { - asyncWork(cancellationToken).GetAwaiter().GetResult(); - } - } - - public class BackgroundAsyncTaskScheduler : IAsyncTaskScheduler - { - public void Schedule(Func asyncWork) - { - Task.Run(asyncWork); - } - - public void Schedule(Func asyncWork) - { - Task.Run(() => asyncWork(CancellationToken.None)); - } - - public void Schedule(Func asyncWork, CancellationToken cancellationToken) - { - Task.Run(() => asyncWork(cancellationToken)); - } - } - - public class FakeLogger : ILogger - { - private readonly List _debugMessages = new List(); - private readonly List _infoMessages = new List(); - private readonly List _warnMessages = new List(); - private readonly List<(string, Exception)> _errorMessages = new List<(string, Exception)>(); - - private readonly object _lock = new object(); - - public void Debug(string message) - { - lock (_lock) - { - _debugMessages.Add(message); - } - } - - public void Info(string message, bool statusBar = false) - { - lock (_lock) - { - _infoMessages.Add(message); - } - } - - public void Warn(string message, bool statusBar = false) - { - lock (_lock) - { - _warnMessages.Add(message); - } - } - - public void Error(string message, Exception ex) - { - lock (_lock) - { - _errorMessages.Add((message, ex)); - } - } - - public List<(string, Exception)> SnapshotErrorMessages() - { - lock (_lock) - { - return new List<(string, Exception)>(_errorMessages); - } - } - - public List SnapshotWarnMessages() - { - lock (_lock) - { - return new List(_warnMessages); - } - } - - public List SnapshotInfoMessages() - { - lock (_lock) - { - return new List(_infoMessages); - } - } - - public List SnapshotDebugMessages() - { - lock (_lock) - { - return new List(_debugMessages); - } - } - - public void ClearErrorMessages() - { - lock (_lock) - { - _errorMessages.Clear(); - } - } - - public void ClearWarnMessages() - { - lock (_lock) - { - _warnMessages.Clear(); - } - } - - public void ClearInfoMessages() - { - lock (_lock) - { - _infoMessages.Clear(); - } - } - - public void ClearDebugMessages() - { - lock (_lock) - { - _debugMessages.Clear(); - } - } - } - - public class FakeCodeReviewer : ICodeReviewer - { - public int ReviewCallCount { get; private set; } - - public bool ThrowOnReview { get; set; } - - public List ReviewedPaths { get; } = new List(); - - public List ReviewedContents { get; } = new List(); - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - ReviewCallCount++; - ReviewedPaths.Add(path); - ReviewedContents.Add(content); - if (ThrowOnReview) - { - throw new Exception("Test exception from code reviewer"); - } - - return Task.FromResult(new FileReviewModel { FilePath = path, RawScore = "8.5" }); - } - - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var review = await ReviewAsync(path, currentCode, false, operationGeneration, cancellationToken); - var baselineRawScore = await GetOrComputeBaselineRawScoreAsync(path, string.Empty, operationGeneration, cancellationToken, baselineCommit); - return (review, baselineRawScore ?? string.Empty); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) => - Task.FromResult("8.0"); - - public FileReviewModel Review(string path, string content) => - ReviewAsync(path, content).GetAwaiter().GetResult(); - - public Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) => - Task.FromResult(null); - - public DeltaResponseModel Delta(FileReviewModel review, string currentCode) => - DeltaAsync(review, currentCode).GetAwaiter().GetResult(); - } - - public class FakeSupportedFileChecker : ISupportedFileChecker - { - private readonly Dictionary _supported = new Dictionary(); - - public bool IsSupported(string filePath) - { - if (_supported.ContainsKey(filePath)) - { - return _supported[filePath]; - } - - var extension = Path.GetExtension(filePath)?.ToLower(); - return extension == ".ts" || extension == ".js" || extension == ".py" || extension == ".cs"; - } - - public void SetSupported(string filePath, bool isSupported) - { - _supported[filePath] = isSupported; - } - } - - public class FakeGitService : IGitService - { - public string GetBaselineCommit(string repoRootPath) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path, string baselineCommit) - { - return string.Empty; - } - - public bool IsFileIgnored(string filePath) - { - return false; - } - - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - return new HashSet(absolutePaths, StringComparer.OrdinalIgnoreCase); - } - - public void Dispose() - { - } - } - - public class FakeGitServiceIgnorePath : IGitService - { - private readonly string _ignoredPath; - - public FakeGitServiceIgnorePath(string ignoredPath) - { - _ignoredPath = ignoredPath; - } - - public string GetBaselineCommit(string repoRootPath) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path, string baselineCommit) - { - return string.Empty; - } - - public bool IsFileIgnored(string filePath) - { - return string.Equals(filePath, _ignoredPath, StringComparison.OrdinalIgnoreCase); - } - - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in absolutePaths) - { - if (!IsFileIgnored(path)) - { - result.Add(path); - } - } - - return result; - } - - public void Dispose() - { - } - } - - public class FakeGitServiceWithGitignoreSupport : IGitService - { - private readonly string _repoPath; - - public FakeGitServiceWithGitignoreSupport(string repoPath) - { - _repoPath = repoPath; - } - - public string GetBaselineCommit(string repoRootPath) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path) - { - return string.Empty; - } - - public string GetFileContentForCommit(string path, string baselineCommit) - { - return string.Empty; - } - - public bool IsFileIgnored(string filePath) - { - try - { - using (var repo = new LibGit2Sharp.Repository(_repoPath)) - { - var relativePath = filePath; - if (Path.IsPathRooted(filePath)) - { - var repoPathNormalized = _repoPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (filePath.StartsWith(repoPathNormalized, StringComparison.OrdinalIgnoreCase)) - { - relativePath = filePath.Substring(repoPathNormalized.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - } - - relativePath = relativePath.Replace('\\', '/'); - - return GitIgnoreSemantics.IsPathIgnoredConsideringIndex(repo, relativePath); - } - } - catch - { - return false; - } - } - - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in absolutePaths) - { - if (!IsFileIgnored(path)) - { - result.Add(path); - } - } - - return result; - } - - public void Dispose() - { - } - } - - public class FakeSavedFilesTracker : ISavedFilesTracker - { - private readonly HashSet _savedFiles = new HashSet(); - - public IEnumerable GetSavedFiles() - { - return _savedFiles; - } - - public void ClearSavedFiles() - { - _savedFiles.Clear(); - } - - public void RemoveFromTracker(string filePath) - { - _savedFiles.Remove(filePath); - } - - public void AddSavedFile(string filePath) - { - _savedFiles.Add(filePath); - } - } - - public class FakeOpenFilesObserver : IOpenFilesObserver - { - private readonly HashSet _openFiles = new HashSet(); - - public string ActiveDocumentPath { get; private set; } - - public IEnumerable GetAllVisibleFileNames() - { - return _openFiles; - } - - public string GetActiveDocumentPath() - { - return ActiveDocumentPath; - } - - public void AddOpenFile(string filePath) - { - _openFiles.Add(filePath); - } - - public void SetActiveDocument(string filePath) - { - ActiveDocumentPath = filePath; - } - } - - public class FakeOpenDocumentContentProvider : IOpenDocumentContentProvider - { - private readonly Dictionary _contentByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public bool ThrowOnGetContent { get; set; } - - public void SetContentForPath(string filePath, string content) - { - _contentByPath[filePath] = content; - } - - public Task GetContentForReviewAsync(string filePath, CancellationToken cancellationToken = default) - { - if (ThrowOnGetContent) - { - throw new Exception("Simulated provider failure"); - } - -#pragma warning disable CS8619 - return Task.FromResult(_contentByPath.TryGetValue(filePath, out var content) ? content : null); -#pragma warning restore CS8619 - } - } - - public class FakeGitChangeLister : IGitChangeLister - { - public event EventHandler> FilesDetected; - - public bool ThrowOnCollectFiles { get; set; } - - public HashSet FilesToReturn { get; set; } = new HashSet(); - - public Task> GetAllChangedFilesAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - return Task.FromResult(new HashSet()); - } - - public Task> GetChangedFilesVsMergeBaseAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - return Task.FromResult(new HashSet()); - } - - public void Initialize(string gitRootPath, IReadOnlyCollection workspacePaths) - { - } - - public void SetWorkspacePaths(IReadOnlyCollection workspacePaths) - { - } - - public void StartPeriodicScanning(CancellationToken cancellationToken) - { - } - - public void StopPeriodicScanning() - { - } - - public Task> CollectFilesFromRepoStateAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - if (ThrowOnCollectFiles) - { - throw new Exception("Simulated error in CollectFilesFromRepoStateAsync"); - } - - return Task.FromResult(FilesToReturn); - } - - public void SimulateFilesDetected(HashSet files) - { - FilesDetected?.Invoke(this, files); - } - } - - internal class TestableGitChangeObserverCore : GitChangeObserverCore - { - public TestableGitChangeObserverCore( - ILogger logger, - ICodeReviewer codeReviewer, - ISupportedFileChecker supportedFileChecker, - IAsyncTaskScheduler taskScheduler, - IGitChangeLister gitChangeLister, - IGitService gitService) - : base(logger, codeReviewer, supportedFileChecker, taskScheduler, gitChangeLister, gitService) - { - } - - public int GetChangedFilesCallCount { get; private set; } - - public void ResetCallCount() - { - GetChangedFilesCallCount = 0; - } - - public override async Task> GetChangedFilesVsBaselineAsync(string baselineCommit) - { - GetChangedFilesCallCount++; - return await base.GetChangedFilesVsBaselineAsync(baselineCommit); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTests.cs deleted file mode 100644 index df081862..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTests.cs +++ /dev/null @@ -1,573 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Reflection; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverCoreTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task GetChangedFilesVsBaseline_ReturnsEmptyArray_ForCleanRepository() - { - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - Assert.IsEmpty(changedFiles, "Should return empty list for clean repository"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_DetectsNewUntrackedFiles() - { - CreateFile("test.ts", "console.log(\"test\");"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - AssertFileInChangedList(changedFiles, "test.ts"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_DetectsModifiedFiles() - { - var testFile = CommitFile("index.js", "console.log(\"hello\");", "Add index.js"); - File.WriteAllText(testFile, "console.log(\"modified\");"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - AssertFileInChangedList(changedFiles, "index.js"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_DetectsStagedFiles() - { - CreateFile("script.py", "print(\"hello\")"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, "script.py"); - } - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - AssertFileInChangedList(changedFiles, "script.py"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_CombinesStatusAndDiffChanges() - { - using (var repo = new Repository(_testRepoPath)) - { - var branch = repo.CreateBranch("feature-branch"); - LibGit2Sharp.Commands.Checkout(repo, branch); - } - - CommitFile("committed.ts", "export const foo = 1;", "Add committed.ts"); - CreateFile("uncommitted.ts", "export const bar = 2;"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - AssertFileInChangedList(changedFiles, "committed.ts"); - AssertFileInChangedList(changedFiles, "uncommitted.ts"); - } - - [DataRow("notes.txt", "Some notes", false, DisplayName = "Rejects unsupported file types (.txt)")] - [DataRow("code.ts", "export const x = 1;", true, DisplayName = "Accepts supported file types (.ts)")] - [TestMethod] - public async Task ShouldProcessFile_ChecksFileSupport(string filename, string content, bool expectedResult) - { - var filePath = CreateFile(filename, content); - _fakeSupportedFileChecker.SetSupported(filePath, expectedResult); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - var result = _gitChangeObserverCore.ShouldProcessFileForTesting(filePath, changedFiles); - - Assert.AreEqual( - expectedResult, - result, - expectedResult ? $"Should process {Path.GetExtension(filename)} files" : $"Should not process {Path.GetExtension(filename)} files"); - } - - [TestMethod] - public async Task HandleFileChange_FiltersFilesNotInChangedList() - { - var changedFile = CreateFile("changed.ts", "export const x = 1;"); - var committedFile = CommitFile("committed.js", "console.log(\"committed\");", "Add committed.js"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "changed.ts"); - AssertFileInChangedList(changedFiles, "committed.js", false); - - await TriggerFileChangeAsync(changedFile); - await TriggerFileChangeAsync(committedFile); - - AssertFileInTracker(changedFile); - AssertFileInTracker(committedFile, false); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_HandlesFilesWithWhitespaceInNames() - { - CreateFile("my file.ts", "console.log(\"has spaces\");"); - CreateFile("test file with spaces.js", "console.log(\"also has spaces\");"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - var fileNames = changedFiles.Select(f => Path.GetFileName(f)).ToList(); - - Assert.Contains("my file.ts", fileNames, "Should include file with spaces: my file.ts"); - Assert.Contains("test file with spaces.js", fileNames, "Should include file with spaces: test file with spaces.js"); - } - - [TestMethod] - public void Dispose_CleansUpFileWatcher() - { - Assert.IsNotNull(_gitChangeObserverCore.FileWatcher, "File watcher should exist"); - - _gitChangeObserverCore.Dispose(); - } - - [TestMethod] - public void Dispose_UnsubscribesAllWatcherHandlers_WhenStarted() - { - _gitChangeObserverCore.Start(); - _gitChangeObserverCore.Dispose(); - } - - [TestMethod] - public void FilesDetected_AfterDispose_DoesNotThrow() - { - _gitChangeObserverCore.Dispose(); - var files = new HashSet { Path.Combine(_testRepoPath, "any.cs") }; - _fakeGitChangeLister.SimulateFilesDetected(files); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_WhenDisposed_ReturnsWithoutThrowing() - { - _gitChangeObserverCore.Dispose(); - var method = typeof(GitChangeObserverCore).GetMethod("OnGitChangeListerFilesDetected", BindingFlags.NonPublic | BindingFlags.Instance); - method.Invoke(_gitChangeObserverCore, new object[] { this, new HashSet() }); - } - - [TestMethod] - public void OnCodeHealthRulesChanged_RemovesAllTrackedFiles() - { - var existingFile = CreateFile("rules-invalidate.ts", "export const x = 1;"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { existingFile }); - AssertFileInTracker(existingFile, true); - - var method = typeof(GitChangeObserverCore).GetMethod("OnCodeHealthRulesChanged", BindingFlags.NonPublic | BindingFlags.Instance); - method.Invoke(_gitChangeObserverCore, new object[] { this, EventArgs.Empty }); - - AssertFileInTracker(existingFile, false); - } - - [TestMethod] - public void Initialize_LogsIdentifiedBaselineBranch_FromConfig() - { - _gitChangeObserverCore.Dispose(); - - var codesceneDir = Path.Combine(_testRepoPath, CodesceneFileWatcher.CodesceneDir); - Directory.CreateDirectory(codesceneDir); - File.WriteAllText( - Path.Combine(codesceneDir, CodesceneFileWatcher.ConfigFileName), - "{\"baseline_branch\":\"develop\"}"); - - _gitChangeObserverCore = CreateGitChangeObserverCore(); - - Assert.IsTrue( - _fakeLogger.SnapshotInfoMessages().Any(m => m.Contains("Baseline branch is 'develop'")), - "Should log configured baseline branch at initialization"); - } - - [TestMethod] - public void Initialize_LogsNoBaselineBranch_WhenNoDefaultAndNoCandidates() - { - using (var repo = new Repository(_testRepoPath)) - { - var featureBranch = repo.CreateBranch("feature-only"); - LibGit2Sharp.Commands.Checkout(repo, featureBranch); - } - - DeleteLocalMainBranches(); - - _gitChangeObserverCore.Dispose(); - _fakeLogger.ClearInfoMessages(); - _gitChangeObserverCore = CreateGitChangeObserverCore(); - - Assert.IsTrue( - _fakeLogger.SnapshotInfoMessages().Any(m => m.Contains("No baseline branch identified.")), - "Should log when no baseline branch and no candidates"); - } - - [TestMethod] - public void Initialize_LogsWarning_WhenBaselineBranchLookupFails() - { - var nonGitDir = Path.Combine(Path.GetTempPath(), $"non-git-root-{Guid.NewGuid()}"); - Directory.CreateDirectory(nonGitDir); - try - { - var gitRootField = typeof(GitChangeObserverCore).GetField("_gitRootPath", BindingFlags.NonPublic | BindingFlags.Instance); - gitRootField?.SetValue(_gitChangeObserverCore, nonGitDir); - - _fakeLogger.ClearWarnMessages(); - var method = typeof(GitChangeObserverCore).GetMethod("LogIdentifiedBaselineBranch", BindingFlags.NonPublic | BindingFlags.Instance); - method.Invoke(_gitChangeObserverCore, null); - - Assert.IsTrue( - _fakeLogger.SnapshotWarnMessages().Any(m => m.Contains("Could not determine baseline branch")), - "Should warn when baseline branch lookup fails"); - } - finally - { - if (Directory.Exists(nonGitDir)) - { - try - { - Directory.Delete(nonGitDir, true); - } - catch - { - } - } - } - } - - [TestMethod] - public void OnCodesceneConfigChanged_RemovesAllTrackedFiles() - { - var existingFile = CreateFile("config-invalidate.ts", "export const x = 1;"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { existingFile }); - AssertFileInTracker(existingFile, true); - - var method = typeof(GitChangeObserverCore).GetMethod("OnCodesceneConfigChanged", BindingFlags.NonPublic | BindingFlags.Instance); - method.Invoke(_gitChangeObserverCore, new object[] { this, EventArgs.Empty }); - - AssertFileInTracker(existingFile, false); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_WhenTokenCancelled_DoesNotProcessFiles() - { - _gitChangeObserverCore.Start(); - var ctsField = typeof(GitChangeObserverCore).GetField("_cts", BindingFlags.NonPublic | BindingFlags.Instance); - var cts = (CancellationTokenSource)ctsField?.GetValue(_gitChangeObserverCore); - cts?.Cancel(); - var filePath = Path.Combine(_testRepoPath, "cancelled.ts"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { filePath }); - AssertFileInTracker(filePath, false); - } - - [TestMethod] - public void Initialize_WithNullSolutionPath_DoesNotCreateWatcher() - { - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = new GitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - _fakeTaskScheduler, - _fakeGitChangeLister, - _fakeGitService); - _gitChangeObserverCore.Initialize(null, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - Assert.IsNull(_gitChangeObserverCore.FileWatcher); - } - - [TestMethod] - public void InitializeTracker_WhenCollectFilesThrows_LogsError() - { - _fakeGitChangeLister.ThrowOnCollectFiles = true; - _fakeLogger.ClearErrorMessages(); - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = CreateGitChangeObserverCore(); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Exists(m => m.Item1.Contains("Error initializing tracker")), - "Should log when CollectFilesFromRepoStateAsync throws"); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_WhenGetChangedFilesThrows_LogsError() - { - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = new GitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - _fakeTaskScheduler, - _fakeGitChangeLister, - _fakeGitService); - _gitChangeObserverCore.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, getChangedFilesCallback: (_) => Task.FromException>(new InvalidOperationException("simulated"))); - _fakeLogger.ClearErrorMessages(); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { Path.Combine(_testRepoPath, "x.cs") }); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Exists(m => m.Item1.Contains("Error processing detected files")), - "Should log when getChangedFiles callback throws"); - } - - [TestMethod] - public void ShouldEnqueueEvent_WithExtensionAndNotIgnored_ReturnsTrue() - { - var method = typeof(GitChangeObserverCore).GetMethod("ShouldEnqueueEvent", BindingFlags.NonPublic | BindingFlags.Instance); - var args = new FileSystemEventArgs(WatcherChangeTypes.Created, _testRepoPath, "file.cs"); - var result = method.Invoke(_gitChangeObserverCore, new object[] { args }); - Assert.IsTrue((bool)result); - } - - [TestMethod] - public void CancelAndReset_ReconnectsEventProcessor() - { - _gitChangeObserverCore.Start(); - _gitChangeObserverCore.CancelAndReset(); - var existingFile = CreateFile("after-reset.ts", "x"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { existingFile }); - AssertFileInTracker(existingFile, true); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_AddsExistingFilesToTracker() - { - var existingFile = CreateFile("tracked.ts", "export const x = 1;"); - var files = new HashSet { existingFile }; - - _fakeGitChangeLister.SimulateFilesDetected(files); - - AssertFileInTracker(existingFile, true); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_SkipsNonExistentFiles() - { - var nonExistentFile = Path.Combine(_testRepoPath, "nonexistent.ts"); - var files = new HashSet { nonExistentFile }; - - _fakeGitChangeLister.SimulateFilesDetected(files); - - AssertFileInTracker(nonExistentFile, false); - } - - [TestMethod] - public async Task OnGitChangeListerFilesDetected_WhenExceptionThrown_LogsError() - { - _fakeLogger.ClearErrorMessages(); - - var eventHandler = typeof(GitChangeObserverCore).GetMethod("OnGitChangeListerFilesDetected", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var exceptionThrown = false; - try - { - eventHandler?.Invoke(_gitChangeObserverCore, new object[] { this, null }); - } - catch - { - exceptionThrown = true; - } - - Assert.IsFalse(exceptionThrown, "Exception should be caught and logged, not thrown"); - - await WaitForConditionAsync(() => _fakeLogger.SnapshotErrorMessages().Count > 0, 2000); - - Assert.IsNotEmpty(_fakeLogger.SnapshotErrorMessages(), "Should log error when exception is thrown"); - Assert.IsTrue( - _fakeLogger.SnapshotErrorMessages().Exists(msg => msg.Item1.Contains("Error processing detected files")), - "Error should mention error processing detected files"); - } - - [TestMethod] - public async Task ProcessDetectedFileQueueAsync_WhenCancelledDuringProcessing_CompletesSilently() - { - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = new GitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - _fakeTaskScheduler, - _fakeGitChangeLister, - _fakeGitService); - _gitChangeObserverCore.Initialize( - _testRepoPath, - _fakeSavedFilesTracker, - _fakeOpenFilesObserver, - getChangedFilesCallback: (_) => Task.FromCanceled>(new CancellationToken(canceled: true))); - _fakeLogger.ClearErrorMessages(); - - var method = typeof(GitChangeObserverCore).GetMethod("ProcessDetectedFileQueueAsync", BindingFlags.NonPublic | BindingFlags.Instance); - var task = (Task)method.Invoke(_gitChangeObserverCore, new object[] { Path.Combine(_testRepoPath, "cancelled.ts"), CancellationToken.None, string.Empty }); - - await task; - - Assert.IsFalse( - _fakeLogger.SnapshotErrorMessages().Exists(msg => msg.Item1.Contains("Error processing detected files")), - "Cancellation should be swallowed without error logs"); - } - - [TestMethod] - public async Task OnGitChangeListerFilesDetected_WhenProcessingFailsForFile_SubsequentDetectionRestartsWorker() - { - var existingFile = CreateFile("retry.ts", "export const x = 1;"); - var getChangedFilesCallCount = 0; - - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = new GitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new BackgroundAsyncTaskScheduler(), - _fakeGitChangeLister, - _fakeGitService); - _gitChangeObserverCore.Initialize( - _testRepoPath, - _fakeSavedFilesTracker, - _fakeOpenFilesObserver, - getChangedFilesCallback: (_) => - { - if (Interlocked.Increment(ref getChangedFilesCallCount) == 1) - { - return Task.FromException>(new InvalidOperationException("simulated")); - } - - return Task.FromResult(new List { existingFile }); - }); - _fakeLogger.ClearWarnMessages(); - - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { existingFile }); - var firstAttemptStarted = await WaitForConditionAsync( - () => getChangedFilesCallCount >= 1, - 2000); - Assert.IsTrue(firstAttemptStarted, "First processing attempt should start for the detected file."); - - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { existingFile }); - var retryProcessed = await WaitForConditionAsync( - () => getChangedFilesCallCount >= 2, - 5000); - - Assert.IsTrue(retryProcessed, "A later detection for the same file should start a new worker after a failure."); - } - - [TestMethod] - public void UpdateWorkspacePaths_UpdatesHandlerAndLister() - { - var paths = new[] { _testRepoPath }; - _gitChangeObserverCore.UpdateWorkspacePaths(paths); - _gitChangeObserverCore.UpdateWorkspacePaths(null); - _gitChangeObserverCore.UpdateWorkspacePaths(Array.Empty()); - } - - [TestMethod] - public void RemoveFromTracker_RemovesFile() - { - var path = CreateFile("track.cs", "x"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { path }); - AssertFileInTracker(path, true); - _gitChangeObserverCore.RemoveFromTracker(path); - AssertFileInTracker(path, false); - } - - [TestMethod] - public void GetTrackerManager_ReturnsNonNull() - { - var manager = _gitChangeObserverCore.GetTrackerManager(); - Assert.IsNotNull(manager); - } - - [TestMethod] - public void EnsureWatcherHandlersBound_CalledTwice_ReturnsEarlySecondTime() - { - _gitChangeObserverCore.Start(); - var method = typeof(GitChangeObserverCore).GetMethod("EnsureWatcherHandlersBound", BindingFlags.NonPublic | BindingFlags.Instance); - method?.Invoke(_gitChangeObserverCore, null); - } - - [TestMethod] - public async Task HandleFileChange_AddsDeltaJobAndRemovesWhenComplete() - { - var testFile = CreateFile("test.cs", "public class Test {}"); - var jobsStarted = new List(); - var jobsFinished = new List(); - - Action onJobStarted = (job) => jobsStarted.Add(job); - Action onJobFinished = (job) => jobsFinished.Add(job); - - Codescene.VSExtension.Core.Util.DeltaJobTracker.JobStarted += onJobStarted; - Codescene.VSExtension.Core.Util.DeltaJobTracker.JobFinished += onJobFinished; - - try - { - await TriggerFileChangeAsync(testFile); - - await WaitForConditionAsync(() => jobsStarted.Count > 0 && jobsFinished.Count > 0, 2000); - - Assert.HasCount(1, jobsStarted, "Should add exactly one delta job"); - Assert.HasCount(1, jobsFinished, "Should remove exactly one delta job"); - Assert.AreEqual(testFile, jobsStarted[0].File.FileName, "Job should track the correct file"); - Assert.AreEqual(testFile, jobsFinished[0].File.FileName, "Finished job should be the same file"); - - var runningJobs = Codescene.VSExtension.Core.Util.DeltaJobTracker.RunningJobs; - Assert.IsEmpty(runningJobs, "No jobs should be running after completion"); - } - finally - { - Codescene.VSExtension.Core.Util.DeltaJobTracker.JobStarted -= onJobStarted; - Codescene.VSExtension.Core.Util.DeltaJobTracker.JobFinished -= onJobFinished; - } - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_PreservesVisibleFilesInCache() - { - var visibleFile = CreateFile("visible.cs", "public class Visible {}"); - var detectedFile = CreateFile("detected.cs", "public class Detected {}"); - - var cacheService = new DeltaCacheService(); - cacheService.Put(new DeltaCacheEntry(visibleFile, "baseline", "current", new DeltaResponseModel { ScoreChange = 1.0m })); - Assert.IsTrue(cacheService.Contains(visibleFile), "Cache should contain visible file before test"); - - _fakeOpenFilesObserver.AddOpenFile(visibleFile); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { detectedFile }); - - Assert.IsTrue(cacheService.Contains(visibleFile), "Visible file should be preserved in cache"); - } - - [TestMethod] - public void OnGitChangeListerFilesDetected_PreservesRunningJobFilesInCache() - { - var jobFile = CreateFile("running-job.cs", "public class RunningJob {}"); - var detectedFile = CreateFile("detected.cs", "public class Detected {}"); - - var cacheService = new DeltaCacheService(); - cacheService.Put(new DeltaCacheEntry(jobFile, "baseline", "current", new DeltaResponseModel { ScoreChange = 1.0m })); - Assert.IsTrue(cacheService.Contains(jobFile), "Cache should contain job file before test"); - - var job = new Job { File = new Codescene.VSExtension.Core.Models.WebComponent.Data.File { FileName = jobFile } }; - DeltaJobTracker.Add(job); - try - { - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { detectedFile }); - - Assert.IsTrue(cacheService.Contains(jobFile), "Running job file should be preserved in cache"); - } - finally - { - DeltaJobTracker.Remove(job); - } - } - - private void DeleteLocalMainBranches() - { - foreach (var branchName in MainBranchNames.All) - { - try - { - ExecGit($"branch -D {branchName}"); - } - catch - { - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverEventQueueTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverEventQueueTests.cs deleted file mode 100644 index c6b003d0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverEventQueueTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Enums.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverEventQueueTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task EventsAreQueued_InsteadOfProcessedImmediately() - { - _gitChangeObserverCore.Start(); - await Task.Delay(1000); - - var fileWatcher = _gitChangeObserverCore.FileWatcher; - fileWatcher.EnableRaisingEvents = false; - - var file1 = CreateFile("queued1.ts", "export const a = 1;"); - var file2 = CreateFile("queued2.ts", "export const b = 2;"); - - var queue = _gitChangeObserverCore.EventQueue; - - var event1 = new FileChangeEvent(FileChangeType.Create, file1); - var event2 = new FileChangeEvent(FileChangeType.Create, file2); - - queue.Enqueue(event1); - queue.Enqueue(event2); - - Assert.HasCount(2, queue, "Events should be queued"); - AssertFileInTracker(file1, false); - AssertFileInTracker(file2, false); - - var queueEmptied = await WaitForConditionAsync(() => queue.Count == 0, 5000); - Assert.IsTrue(queueEmptied, "Queue should be empty after processing"); - - var trackerManager = _gitChangeObserverCore.GetTrackerManager(); - var file1InTracker = await WaitForConditionAsync(() => trackerManager.Contains(file1), 5000); - var file2InTracker = await WaitForConditionAsync(() => trackerManager.Contains(file2), 5000); - - Assert.IsTrue(file1InTracker, "File1 should be in tracker after processing"); - Assert.IsTrue(file2InTracker, "File2 should be in tracker after processing"); - } - - [TestMethod] - public async Task GetChangedFilesVsBaseline_CalledOncePerBatch_NotPerFile() - { - var observer = new TestableGitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new FakeAsyncTaskScheduler(), - _fakeGitChangeLister, - _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - - observer.Start(); - await Task.Delay(1000); - - var files = new[] - { - CreateFile("cache1.ts", "export const a = 1;"), - CreateFile("cache2.ts", "export const b = 2;"), - CreateFile("cache3.ts", "export const c = 3;"), - }; - - var queue = observer.EventQueue; - - observer.ResetCallCount(); - - foreach (var file in files) - { - var evt = new FileChangeEvent(FileChangeType.Create, file); - queue.Enqueue(evt); - } - - var initialCount = observer.GetChangedFilesCallCount; - - var methodCalled = await WaitForConditionAsync(() => observer.GetChangedFilesCallCount > initialCount, 5000); - Assert.IsTrue(methodCalled, "GetChangedFilesVsBaselineAsync should be called after batch processing"); - - Assert.AreEqual(initialCount + 1, observer.GetChangedFilesCallCount, "GetChangedFilesVsBaselineAsync should be called exactly once per batch"); - - var trackerManager = observer.GetTrackerManager(); - foreach (var file in files) - { - var fileInTracker = await WaitForConditionAsync(() => trackerManager.Contains(file), 5000); - Assert.IsTrue(fileInTracker, $"File {file} should be in tracker"); - } - - observer.Dispose(); - } - - [TestMethod] - public async Task EmptyQueue_DoesNotTrigger_UnnecessaryProcessing() - { - var observer = new TestableGitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - new FakeAsyncTaskScheduler(), - _fakeGitChangeLister, - _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - - await Task.Delay(1000); - - observer.Start(); - await Task.Delay(500); - - observer.ResetCallCount(); - - await Task.Delay(5000); - - Assert.AreEqual(0, observer.GetChangedFilesCallCount, "GetChangedFilesVsBaselineAsync should not be called when queue is empty"); - - observer.Dispose(); - } - - [TestMethod] - public void Dispose_CleansUpScheduledTimer() - { - _gitChangeObserverCore.Start(); - - Assert.IsNotNull(_gitChangeObserverCore.ScheduledTimer, "Scheduled timer should exist before disposal"); - - _gitChangeObserverCore.Dispose(); - - Assert.IsNull(_gitChangeObserverCore.ScheduledTimer, "Scheduled timer should be null after disposal"); - } - - [TestMethod] - public void FilesDetected_WithAbsolutePaths_AddsCorrectPathToTracker() - { - var absolutePath = Path.Combine(_testRepoPath, "detected.ts"); - File.WriteAllText(absolutePath, "export const x = 1;"); - - var detectedFiles = new HashSet { absolutePath }; - - _fakeGitChangeLister.SimulateFilesDetected(detectedFiles); - - var trackerManager = _gitChangeObserverCore.GetTrackerManager(); - Assert.IsTrue( - trackerManager.Contains(absolutePath), - $"Tracker should contain the absolute path '{absolutePath}'"); - } - - [TestMethod] - public async Task IgnoredFile_DoesNotEnqueueWatcherEvent_NotInTracker() - { - var ignoredPath = Path.Combine(_testRepoPath, "ignored.cs"); - var gitService = new FakeGitServiceIgnorePath(ignoredPath); - var observer = new TestableGitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - _fakeTaskScheduler, - _fakeGitChangeLister, - gitService); - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - observer.Start(); - await Task.Delay(1000); - - CreateFile("ignored.cs", "content"); - await Task.Delay(3000); - - var inTracker = observer.GetTrackerManager().Contains(ignoredPath); - observer.Dispose(); - Assert.IsFalse(inTracker, "Ignored file should not be enqueued and thus not in tracker"); - } - - [TestMethod] - public async Task FileWithoutExtension_DoesNotEnqueueWatcherEvent_NotInTracker() - { - _gitChangeObserverCore.Start(); - await Task.Delay(1000); - - var pathNoExt = Path.Combine(_testRepoPath, "Makefile"); - File.WriteAllText(pathNoExt, "content"); - await Task.Delay(3000); - - Assert.IsFalse( - _gitChangeObserverCore.GetTrackerManager().Contains(pathNoExt), - "File without extension should not be enqueued and thus not in tracker"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverGitignoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverGitignoreTests.cs deleted file mode 100644 index 15b1d936..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverGitignoreTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverGitignoreTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task DeletedFileOnBranchWithGitignore_IsNotInChangedListAndNotReviewed() - { - var fileName = "gitignored.ts"; - CommitFile(fileName, "export const x = 1;", "Add file on main"); - - using (var repo = new Repository(_testRepoPath)) - { - var branch = repo.CreateBranch("feature"); - LibGit2Sharp.Commands.Checkout(repo, branch); - } - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, fileName + "\n"); - var filePath = Path.Combine(_testRepoPath, fileName); - File.Delete(filePath); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, new[] { ".gitignore", fileName }); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Add to gitignore and remove file", signature, signature); - } - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, fileName, false); - - var shouldProcess = _gitChangeObserverCore.ShouldProcessFileForTesting(filePath, changedFiles); - Assert.IsFalse(shouldProcess, "Deleted and ignored file should not be processed for review"); - } - - [TestMethod] - public async Task GitIgnoredFiles_AreNotTracked() - { - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "*.ignored\n"); - - var ignoredFile = CreateFile("secret.ignored", "export const secret = \"hidden\";"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "secret.ignored", false); - - await TriggerFileChangeAsync(ignoredFile); - - AssertFileInTracker(ignoredFile, false); - - File.Delete(gitignorePath); - } - - [TestMethod] - public async Task TrackedGitignoredFile_IsReviewedAndShown() - { - _gitChangeObserverCore.Dispose(); - _fakeGitService = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - _gitChangeObserverCore = CreateGitChangeObserverCore(); - - var fileName = "tracked-but-ignored.cs"; - var filePath = CommitFile(fileName, "public class Tracked {}", "Add file"); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, fileName + "\n"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, new[] { ".gitignore", fileName }); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Keep file tracked under gitignore", signature, signature); - } - - File.WriteAllText(filePath, "public class Tracked { /* modified */ }"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, fileName); - - var shouldProcess = _gitChangeObserverCore.ShouldProcessFileForTesting(filePath, changedFiles); - Assert.IsTrue(shouldProcess, "Tracked file matching gitignore should be processed for review"); - - await TriggerFileChangeAsync(filePath); - AssertFileInTracker(filePath); - } - - [TestMethod] - public async Task FileBecomesTracked_AfterGitignoreRemoval() - { - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "config.ts\n"); - - var ignoredFile = CreateFile("config.ts", "export const config = { secret: true };"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "config.ts", false); - - await TriggerFileChangeAsync(ignoredFile); - AssertFileInTracker(ignoredFile, false); - - File.Delete(gitignorePath); - await Task.Delay(100); - - changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "config.ts"); - - await TriggerFileChangeAsync(ignoredFile); - - AssertFileInTracker(ignoredFile); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverNaturalEventTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverNaturalEventTests.cs deleted file mode 100644 index d2eccef6..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverNaturalEventTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverNaturalEventTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task NaturalEvents_FilesEventuallyTracked() - { - _gitChangeObserverCore.Start(); - await Task.Delay(1500); - - var file1 = CreateFile("queued1.ts", "export const a = 1;"); - var file2 = CreateFile("queued2.ts", "export const b = 2;"); - - await Task.Delay(100); - - var file1Tracked = await WaitForConditionAsync( - () => - _gitChangeObserverCore.GetTrackerManager().Contains(file1), 5000); - var file2Tracked = await WaitForConditionAsync( - () => - _gitChangeObserverCore.GetTrackerManager().Contains(file2), 5000); - - Assert.IsTrue(file1Tracked, "File1 should eventually be tracked"); - Assert.IsTrue(file2Tracked, "File2 should eventually be tracked"); - } - - [TestMethod] - public async Task NaturalEvents_FileModification_DetectedAndTracked() - { - _gitChangeObserverCore.Start(); - await Task.Delay(1500); - - var file = CreateFile("modify-test.ts", "export const x = 1;"); - - var trackerManager = _gitChangeObserverCore.GetTrackerManager(); - var fileTracked = await WaitForConditionAsync(() => trackerManager.Contains(file), 5000); - Assert.IsTrue(fileTracked, "File should be tracked after creation"); - - File.WriteAllText(file, "export const x = 2;"); - - await Task.Delay(2000); - - var stillTracked = trackerManager.Contains(file); - Assert.IsTrue(stillTracked, "File should still be tracked after modification"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverRaceConditionTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverRaceConditionTests.cs deleted file mode 100644 index 165a8221..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverRaceConditionTests.cs +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Concurrent; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cli.Delta; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverRaceConditionTests : GitChangeObserverTestBase - { - private DeltaCacheService _deltaCache; - - [TestInitialize] - public void SetupRaceConditionTests() - { - _deltaCache = new DeltaCacheService(new ConcurrentDictionary()); - } - - [TestMethod] - public async Task FileCreation_WithoutDeletion_ShowsInCodeHealthMonitor() - { - var aboutToStore = new ManualResetEventSlim(false); - var canProceed = new ManualResetEventSlim(true); - var blockingReviewer = new BlockingCodeReviewer(aboutToStore, canProceed, _deltaCache); - - var observer = new GitChangeObserverCore( - _fakeLogger, blockingReviewer, _fakeSupportedFileChecker, _fakeTaskScheduler, _fakeGitChangeLister, _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - - var testFile = CreateFile("test.cs", "public class Test {}"); - var changedFiles = await observer.GetChangedFilesVsBaselineAsync(string.Empty); - - await observer.HandleFileChangeForTestingAsync(testFile, changedFiles); - - await WaitForConditionAsync(() => aboutToStore.IsSet, 5000); - - var filesInMonitor = _deltaCache.GetAll(); - Assert.IsTrue( - filesInMonitor.ContainsKey(testFile), - "Created file should appear in Code Health Monitor"); - - observer.Dispose(); - } - - [TestMethod] - public async Task FileCreatedThenDeletedDuringReview_ShouldNotShowInCodeHealthMonitor() - { - var aboutToStore = new ManualResetEventSlim(false); - var canProceed = new ManualResetEventSlim(false); - var blockingReviewer = new BlockingCodeReviewer(aboutToStore, canProceed, _deltaCache); - - var observer = new GitChangeObserverCore( - _fakeLogger, blockingReviewer, _fakeSupportedFileChecker, _fakeTaskScheduler, _fakeGitChangeLister, _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - - var testFile = CreateFile("test.cs", "public class Test {}"); - var changedFiles = await observer.GetChangedFilesVsBaselineAsync(string.Empty); - - var changeTask = Task.Run(async () => - await observer.HandleFileChangeForTestingAsync(testFile, changedFiles)); - - var waitResult = aboutToStore.Wait(5000); - Assert.IsTrue(waitResult, "Should reach the point before Put() within timeout"); - - File.Delete(testFile); - await observer.HandleFileDeleteForTestingAsync(testFile, changedFiles); - - canProceed.Set(); - await changeTask; - - var filesInMonitor = _deltaCache.GetAll(); - Assert.IsFalse( - filesInMonitor.ContainsKey(testFile), - "Deleted file should NOT appear in Code Health Monitor"); - - observer.Dispose(); - } - - [TestMethod] - public async Task CancelAndReset_WhileReviewInFlight_PreventsCachePut() - { - var aboutToStore = new ManualResetEventSlim(false); - var canProceed = new ManualResetEventSlim(false); - var deltaCache = new DeltaCacheService(new ConcurrentDictionary()); - var blockingReviewer = new CancellationAwareBlockingCodeReviewer(aboutToStore, canProceed, deltaCache); - var backgroundScheduler = new BackgroundAsyncTaskScheduler(); - - var observer = new GitChangeObserverCore( - _fakeLogger, blockingReviewer, _fakeSupportedFileChecker, backgroundScheduler, _fakeGitChangeLister, _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - observer.Start(); - - var testFile = CreateFile("test.cs", "public class Test {}"); - _fakeGitChangeLister.SimulateFilesDetected(new HashSet { testFile }); - - var waitResult = aboutToStore.Wait(5000); - Assert.IsTrue(waitResult, "Review should reach the point before Put() within timeout"); - - observer.CancelAndReset(); - - canProceed.Set(); - await Task.Delay(200); - - var filesInCache = deltaCache.GetAll(); - Assert.IsFalse( - filesInCache.ContainsKey(testFile), - "Cancelled in-flight review should NOT populate the cache"); - - observer.Dispose(); - } - - [TestMethod] - public async Task FilesDetected_WhileReviewInFlight_DoesNotRunParallelReviewForSameFile() - { - var reviewStarted = new ManualResetEventSlim(false); - var unblockReview = new ManualResetEventSlim(false); - var countingReviewer = new ParallelBlockingCodeReviewer(reviewStarted, unblockReview); - var backgroundScheduler = new BackgroundAsyncTaskScheduler(); - - var observer = new GitChangeObserverCore( - _fakeLogger, countingReviewer, _fakeSupportedFileChecker, backgroundScheduler, _fakeGitChangeLister, _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - observer.Start(); - - var testFile = CreateFile("same-file.cs", "public class SameFile {}"); - var detected = new HashSet { testFile }; - _fakeGitChangeLister.SimulateFilesDetected(detected); - - var waitResult = reviewStarted.Wait(5000); - Assert.IsTrue(waitResult, "Review should start."); - - _fakeGitChangeLister.SimulateFilesDetected(detected); - await Task.Delay(200); - - Assert.AreEqual(1, countingReviewer.StartedCount, "Second scan should not start parallel work for same file while first run is active."); - Assert.AreEqual(1, countingReviewer.MaxParallelism, "Only one in-flight review should be active for the file."); - - unblockReview.Set(); - await Task.Delay(300); - - observer.Dispose(); - } - - private class BlockingCodeReviewer : ICodeReviewer - { - private readonly ManualResetEventSlim _aboutToStore; - private readonly ManualResetEventSlim _canProceed; - private readonly DeltaCacheService _cache; - - public BlockingCodeReviewer( - ManualResetEventSlim aboutToStore, ManualResetEventSlim canProceed, DeltaCacheService cache) - { - _aboutToStore = aboutToStore; - _canProceed = canProceed; - _cache = cache; - } - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new FileReviewModel { FilePath = path, RawScore = "8.5" }); - } - - public Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - _aboutToStore.Set(); - _canProceed.Wait(); - - var delta = new DeltaResponseModel { ScoreChange = -0.5m }; - var entry = new DeltaCacheEntry(review.FilePath, string.Empty, currentCode, delta); - _cache.Put(entry); - - return Task.FromResult(delta); - } - - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var review = await ReviewAsync(path, currentCode, false, operationGeneration, cancellationToken); - var baselineRawScore = await GetOrComputeBaselineRawScoreAsync(path, string.Empty, operationGeneration, cancellationToken, baselineCommit); - return (review, baselineRawScore ?? string.Empty); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return Task.FromResult("8.0"); - } - } - - private class CancellationAwareBlockingCodeReviewer : ICodeReviewer - { - private readonly ManualResetEventSlim _aboutToStore; - private readonly ManualResetEventSlim _canProceed; - private readonly DeltaCacheService _cache; - - public CancellationAwareBlockingCodeReviewer( - ManualResetEventSlim aboutToStore, ManualResetEventSlim canProceed, DeltaCacheService cache) - { - _aboutToStore = aboutToStore; - _canProceed = canProceed; - _cache = cache; - } - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new FileReviewModel { FilePath = path, RawScore = "8.5" }); - } - - public Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - _aboutToStore.Set(); - _canProceed.Wait(); - - cancellationToken.ThrowIfCancellationRequested(); - - var delta = new DeltaResponseModel { ScoreChange = -0.5m }; - var entry = new DeltaCacheEntry(review.FilePath, string.Empty, currentCode, delta); - _cache.Put(entry); - - return Task.FromResult(delta); - } - - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var review = await ReviewAsync(path, currentCode, false, operationGeneration, cancellationToken); - var baselineRawScore = await GetOrComputeBaselineRawScoreAsync(path, string.Empty, operationGeneration, cancellationToken, baselineCommit); - return (review, baselineRawScore ?? string.Empty); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return Task.FromResult("8.0"); - } - } - - private class ParallelBlockingCodeReviewer : ICodeReviewer - { - private readonly ManualResetEventSlim _reviewStarted; - private readonly ManualResetEventSlim _unblockReview; - private int _activeCount; - private int _startedCount; - private int _maxParallelism; - - public ParallelBlockingCodeReviewer(ManualResetEventSlim reviewStarted, ManualResetEventSlim unblockReview) - { - _reviewStarted = reviewStarted; - _unblockReview = unblockReview; - } - - public int StartedCount => _startedCount; - - public int MaxParallelism => _maxParallelism; - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new FileReviewModel { FilePath = path, RawScore = "8.5" }); - } - - public Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - Interlocked.Increment(ref _startedCount); - var currentActive = Interlocked.Increment(ref _activeCount); - var recordedMaxParallelism = _maxParallelism; - while (currentActive > recordedMaxParallelism) - { - var originalMaxParallelism = Interlocked.CompareExchange(ref _maxParallelism, currentActive, recordedMaxParallelism); - if (originalMaxParallelism == recordedMaxParallelism) - { - break; - } - - recordedMaxParallelism = originalMaxParallelism; - } - - _reviewStarted.Set(); - _unblockReview.Wait(); - - Interlocked.Decrement(ref _activeCount); - return Task.FromResult(null); - } - - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var review = await ReviewAsync(path, currentCode, false, operationGeneration, cancellationToken); - var baselineRawScore = await GetOrComputeBaselineRawScoreAsync(path, string.Empty, operationGeneration, cancellationToken, baselineCommit); - return (review, baselineRawScore ?? string.Empty); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return Task.FromResult("8.0"); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTestBase.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTestBase.cs deleted file mode 100644 index 75d0a006..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTestBase.cs +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - public abstract class GitChangeObserverTestBase - { - protected string _testRepoPath; - protected GitChangeObserverCore _gitChangeObserverCore; - protected FakeLogger _fakeLogger; - protected Core.Interfaces.Cli.ICodeReviewer _fakeCodeReviewer; - protected FakeCodeReviewer _innerFakeReviewer; - protected FakeSupportedFileChecker _fakeSupportedFileChecker; - protected FakeAsyncTaskScheduler _fakeTaskScheduler; - protected FakeSavedFilesTracker _fakeSavedFilesTracker; - protected FakeOpenFilesObserver _fakeOpenFilesObserver; - protected FakeGitChangeLister _fakeGitChangeLister; - protected Core.Interfaces.Git.IGitService _fakeGitService; - - [TestInitialize] - public void Setup() - { - MainBranchNames.ClearDefaultBranchCache(); - - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-git-repo-observer-{Guid.NewGuid()}"); - - if (Directory.Exists(_testRepoPath)) - { - Directory.Delete(_testRepoPath, true); - } - - Directory.CreateDirectory(_testRepoPath); - - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - repo.Config.Set("advice.defaultBranchName", "false"); - } - - var gitInfoDir = Path.Combine(_testRepoPath, ".git", "info"); - Directory.CreateDirectory(gitInfoDir); - var dummyExcludesPath = Path.Combine(gitInfoDir, "exclude-test"); - File.WriteAllText(dummyExcludesPath, "# Test excludes file - will not match anything\n__xxxxxxxxxxxxx__\n"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("core.excludesfile", dummyExcludesPath); - } - - CommitFile("README.md", "# Test Repository", "Initial commit"); - - _fakeLogger = new FakeLogger(); - _fakeSupportedFileChecker = new FakeSupportedFileChecker(); - _fakeTaskScheduler = new FakeAsyncTaskScheduler(); - _fakeSavedFilesTracker = new FakeSavedFilesTracker(); - _fakeOpenFilesObserver = new FakeOpenFilesObserver(); - _fakeGitChangeLister = new FakeGitChangeLister(); - _fakeGitService = new FakeGitService(); - - _innerFakeReviewer = new FakeCodeReviewer(); - var notifier = new Core.Application.Services.CodeHealthMonitorNotifier(); - _fakeCodeReviewer = new Core.Application.Cli.CachingCodeReviewer( - innerReviewer: _innerFakeReviewer, - cache: null, - baselineCache: null, - deltaCache: null, - logger: _fakeLogger, - git: _fakeGitService, - telemetryManager: null, - notifier: notifier); - - _gitChangeObserverCore = CreateGitChangeObserverCore(); - - Thread.Sleep(500); - } - - [TestCleanup] - public void Cleanup() - { - _gitChangeObserverCore?.Dispose(); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - if (File.Exists(gitignorePath)) - { - File.Delete(gitignorePath); - } - - if (Directory.Exists(_testRepoPath)) - { - try - { - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - protected GitChangeObserverCore CreateGitChangeObserverCore() - { - var observer = new GitChangeObserverCore( - _fakeLogger, - _fakeCodeReviewer, - _fakeSupportedFileChecker, - _fakeTaskScheduler, - _fakeGitChangeLister, - _fakeGitService); - - observer.Initialize(_testRepoPath, _fakeSavedFilesTracker, _fakeOpenFilesObserver, null); - - return observer; - } - - protected GitChangeObserverCore CreateGitChangeObserver() - { - return CreateGitChangeObserverCore(); - } - - protected void ExecGit(string args) - { - var psi = new ProcessStartInfo - { - FileName = "git", - Arguments = args, - WorkingDirectory = _testRepoPath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using (var process = Process.Start(psi)) - { - process.WaitForExit(); - - if (process.ExitCode != 0) - { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"Git command failed: {args}\n{error}"); - } - } - } - - protected string CreateFile(TestFileData fileData) - { - var filePath = Path.Combine(_testRepoPath, fileData.Filename); - File.WriteAllText(filePath, fileData.Content); - return filePath; - } - - protected string CreateFile(string filename, string content) - { - return CreateFile(new TestFileData(filename, content)); - } - - protected string CommitFile(TestFileData fileData) - { - var filePath = CreateFile(fileData); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, fileData.Filename); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit(fileData.CommitMessage, signature, signature); - } - - return filePath; - } - - protected string CommitFile(string filename, string content, string message) - { - return CommitFile(new TestFileData(filename, content, message)); - } - - protected TrackerManager GetTrackerManager() - { - return _gitChangeObserverCore.GetTrackerManager(); - } - - protected async Task TriggerFileChangeAsync(string filePath) - { - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - await _gitChangeObserverCore.HandleFileChangeForTestingAsync(filePath, changedFiles); - } - - protected FileAssertionHelper CreateAssertionHelper(List changedFiles) - { - return new FileAssertionHelper(changedFiles, GetTrackerManager()); - } - - protected void AssertFileInChangedList(List changedFiles, string filename, bool shouldExist = true) - { - CreateAssertionHelper(changedFiles).AssertInChangedList(filename, shouldExist); - } - - protected void AssertFileInTracker(string filePath, bool shouldExist = true) - { - CreateAssertionHelper(null).AssertInTracker(filePath, shouldExist); - } - - protected async Task WaitForConditionAsync(Func condition, int timeoutMs = 5000, int pollIntervalMs = 100) - { - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - if (condition()) - { - return true; - } - - await Task.Delay(pollIntervalMs); - } - - return false; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTrackerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTrackerTests.cs deleted file mode 100644 index bb8919b6..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTrackerTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverTrackerTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task TrackerTracksAddedFiles() - { - var newFile = CreateFile("tracked.ts", "export const x = 1;"); - _gitChangeObserverCore.Start(); - await Task.Delay(500); - - await TriggerFileChangeAsync(newFile); - - AssertFileInTracker(newFile); - } - - [TestMethod] - public async Task RemoveFromTracker_RemovesFileFromTracking() - { - var newFile = CreateFile("removable.ts", "export const x = 1;"); - _gitChangeObserverCore.Start(); - await Task.Delay(500); - await TriggerFileChangeAsync(newFile); - AssertFileInTracker(newFile); - - _gitChangeObserverCore.RemoveFromTracker(newFile); - - AssertFileInTracker(newFile, false); - } - - [TestMethod] - public async Task HandleFileDelete_RemovesTrackedFile() - { - var newFile = CreateFile("deletable.ts", "export const x = 1;"); - _gitChangeObserverCore.Start(); - await Task.Delay(500); - await TriggerFileChangeAsync(newFile); - AssertFileInTracker(newFile); - - File.Delete(newFile); - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - await _gitChangeObserverCore.HandleFileDeleteForTestingAsync(newFile, changedFiles); - - AssertFileInTracker(newFile, false); - } - - [TestMethod] - public async Task HandleFileDelete_RaisesViewUpdateRequestedEvent() - { - var eventRaised = false; - _gitChangeObserverCore.ViewUpdateRequested += (sender, e) => eventRaised = true; - - var newFile = CreateFile("event-test.ts", "export const x = 1;"); - _gitChangeObserverCore.Start(); - await Task.Delay(500); - await TriggerFileChangeAsync(newFile); - AssertFileInTracker(newFile); - - File.Delete(newFile); - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - await _gitChangeObserverCore.HandleFileDeleteForTestingAsync(newFile, changedFiles); - - Assert.IsTrue(eventRaised, "ViewUpdateRequested event should be raised when a tracked file is deleted"); - } - - [TestMethod] - public async Task HandleFileDelete_HandlesDirectoryDeletion() - { - var subDir = Path.Combine(_testRepoPath, "subdir"); - Directory.CreateDirectory(subDir); - var file1 = Path.Combine(subDir, "file1.ts"); - var file2 = Path.Combine(subDir, "file2.ts"); - File.WriteAllText(file1, "export const a = 1;"); - File.WriteAllText(file2, "export const b = 2;"); - - _gitChangeObserverCore.Start(); - await Task.Delay(500); - await TriggerFileChangeAsync(file1); - await TriggerFileChangeAsync(file2); - AssertFileInTracker(file1); - AssertFileInTracker(file2); - - Directory.Delete(subDir, true); - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - await _gitChangeObserverCore.HandleFileDeleteForTestingAsync(subDir, changedFiles); - - AssertFileInTracker(file1, false); - AssertFileInTracker(file2, false); - } - - [TestMethod] - public async Task HandleFileDelete_FiresDeleteEvent_WhenFileWasOpenInEditorDuringInit() - { - var testFile = CreateFile("open-in-editor.cs", "public class Test {}"); - - _fakeOpenFilesObserver.AddOpenFile(testFile); - - _fakeGitChangeLister.FilesToReturn = new HashSet { testFile }; - - _gitChangeObserverCore.Dispose(); - _gitChangeObserverCore = CreateGitChangeObserverCore(); - await Task.Delay(500); - - AssertFileInTracker(testFile, true); - - var eventRaised = false; - _gitChangeObserverCore.ViewUpdateRequested += (sender, e) => eventRaised = true; - - File.Delete(testFile); - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - CollectionAssert.AreEqual(new List(), changedFiles, "Changed list should be empty after git stash"); - - await _gitChangeObserverCore.HandleFileDeleteForTestingAsync(testFile, changedFiles); - - Assert.IsTrue(eventRaised, "ViewUpdateRequested should be raised when deleting a file that was open in editor during init"); - AssertFileInTracker(testFile, false); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverWorkflowTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverWorkflowTests.cs deleted file mode 100644 index cbedf503..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverWorkflowTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class GitChangeObserverWorkflowTests : GitChangeObserverTestBase - { - [TestMethod] - public async Task MultiStepWorkflow_StagingAndUnstagingFiles() - { - var file = CreateFile("staging-test.ts", "export const test = 1;"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Stage(repo, "staging-test.ts"); - } - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "staging-test.ts"); - - using (var repo = new Repository(_testRepoPath)) - { - LibGit2Sharp.Commands.Unstage(repo, "staging-test.ts"); - } - - changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "staging-test.ts"); - - File.Delete(file); - changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "staging-test.ts", false); - } - - [TestMethod] - public async Task ComplexGit_DetachedHeadState_HandledGracefully() - { - string commitSha; - using (var repo = new Repository(_testRepoPath)) - { - commitSha = repo.Head.Tip.Sha; - } - - ExecGit($"checkout {commitSha}"); - - var file = CreateFile("detached.ts", "export const detached = 1;"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, "detached.ts"); - - Assert.IsNotNull(changedFiles, "Should return results even in detached HEAD state"); - } - - [TestMethod] - public async Task ComplexGit_MergeBranch_DetectsAllChanges() - { - using (var repo = new Repository(_testRepoPath)) - { - var branch = repo.CreateBranch("merge-feature"); - LibGit2Sharp.Commands.Checkout(repo, branch); - } - - CommitFile("merge1.ts", "export const a = 1;", "Add merge1"); - CommitFile("merge2.ts", "export const b = 2;", "Add merge2"); - - using (var repo = new Repository(_testRepoPath)) - { - var mainBranch = repo.Branches["master"] ?? repo.Branches["main"]; - LibGit2Sharp.Commands.Checkout(repo, mainBranch); - } - - ExecGit("merge merge-feature --no-ff --no-edit"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - - AssertFileInChangedList(changedFiles, "merge1.ts", false); - AssertFileInChangedList(changedFiles, "merge2.ts", false); - } - - [TestMethod] - public async Task FileModificationAndRevertCycle_UpdatesCodeHealthMonitor() - { - var fileName = "healthy-file.ts"; - var originalContent = "export function hello() { return \"world\"; }"; - var filePath = CommitFile(fileName, originalContent, "Add healthy file"); - - var changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, fileName, false); - - var modifiedContent = "export function hello() { return \"modified\"; }"; - File.WriteAllText(filePath, modifiedContent); - - await TriggerFileChangeAsync(filePath); - changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, fileName, true); - AssertFileInTracker(filePath, true); - - File.WriteAllText(filePath, originalContent); - - changedFiles = await _gitChangeObserverCore.GetChangedFilesVsBaselineAsync(string.Empty); - AssertFileInChangedList(changedFiles, fileName, false); - - await TriggerFileChangeAsync(filePath); - - AssertFileInTracker(filePath, false); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitIgnoreCacheBustingTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitIgnoreCacheBustingTests.cs deleted file mode 100644 index 3afe4747..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitIgnoreCacheBustingTests.cs +++ /dev/null @@ -1,458 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - public class GitIgnoreCacheBustingTestsBase : GitChangeObserverTestBase - { - [TestInitialize] - public new void Setup() - { - base.Setup(); - OnAfterSetup(); - } - - protected virtual void OnAfterSetup() - { - } - - protected void SetupWithGitignoreSupport() - { - _gitChangeObserverCore?.Dispose(); - _fakeGitService = new FakeGitServiceWithGitignoreSupport(_testRepoPath); - _gitChangeObserverCore = CreateGitChangeObserverCore(); - Thread.Sleep(500); - } - } - - [TestClass] - public class GitIgnoreCacheBustingTests : GitIgnoreCacheBustingTestsBase - { - [TestMethod] - public async Task FileAddedToGitignore_IsRemovedFromTracker() - { - var trackedFile = CreateFile("secrets.ts", "export const apiKey = 'secret';"); - await TriggerFileChangeAsync(trackedFile); - AssertFileInTracker(trackedFile, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "secrets.ts\n"); - - await Task.Delay(200); - - AssertFileInTracker(trackedFile, false); - } - - [TestMethod] - public async Task MultipleFilesAddedToGitignore_AllRemovedFromTracker() - { - var file1 = CreateFile("config1.ts", "export const config = {};"); - var file2 = CreateFile("config2.ts", "export const config2 = {};"); - await TriggerFileChangeAsync(file1); - await TriggerFileChangeAsync(file2); - AssertFileInTracker(file1, true); - AssertFileInTracker(file2, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "config1.ts\nconfig2.ts\n"); - - await Task.Delay(200); - - AssertFileInTracker(file1, false); - AssertFileInTracker(file2, false); - } - - [TestMethod] - public async Task GitignoreInSubdirectory_RemovesMatchingFiles() - { - var subDir = Path.Combine(_testRepoPath, "src"); - Directory.CreateDirectory(subDir); - - var file1 = Path.Combine(subDir, "data.ts"); - File.WriteAllText(file1, "export const data = 123;"); - await TriggerFileChangeAsync(file1); - AssertFileInTracker(file1, true); - - var gitignorePath = Path.Combine(subDir, ".gitignore"); - File.WriteAllText(gitignorePath, "data.ts\n"); - - await Task.Delay(200); - - AssertFileInTracker(file1, false); - } - - [TestMethod] - public async Task GitignorePatternWithWildcard_RemovesMatchingFiles() - { - var file1 = CreateFile("test1.js", "console.log('test1');"); - var file2 = CreateFile("test2.js", "console.log('test2');"); - var file3 = CreateFile("app.ts", "export const test = 1;"); - await TriggerFileChangeAsync(file1); - await TriggerFileChangeAsync(file2); - await TriggerFileChangeAsync(file3); - AssertFileInTracker(file1, true); - AssertFileInTracker(file2, true); - AssertFileInTracker(file3, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "test*.js\n"); - - await Task.Delay(200); - - AssertFileInTracker(file1, false); - AssertFileInTracker(file2, false); - AssertFileInTracker(file3, true); - } - - [TestMethod] - public async Task GitignoreDeleted_FileBecomesTrackedAgain() - { - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "ignored.ts\n"); - - var ignoredFile = CreateFile("ignored.ts", "export const ignored = true;"); - await TriggerFileChangeAsync(ignoredFile); - AssertFileInTracker(ignoredFile, false); - - File.Delete(gitignorePath); - await Task.Delay(200); - - await TriggerFileChangeAsync(ignoredFile); - AssertFileInTracker(ignoredFile, true); - } - - [TestMethod] - public async Task GitignoreModified_UpdatesTrackedFiles() - { - var file1 = CreateFile("temp1.ts", "export const temp1 = 1;"); - var file2 = CreateFile("temp2.ts", "export const temp2 = 2;"); - await TriggerFileChangeAsync(file1); - await TriggerFileChangeAsync(file2); - AssertFileInTracker(file1, true); - AssertFileInTracker(file2, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "temp1.ts\n"); - await Task.Delay(200); - - AssertFileInTracker(file1, false); - AssertFileInTracker(file2, true); - - File.WriteAllText(gitignorePath, "temp2.ts\n"); - await Task.Delay(200); - - await TriggerFileChangeAsync(file1); - AssertFileInTracker(file1, true); - AssertFileInTracker(file2, false); - } - - [TestMethod] - public async Task NonIgnoredFiles_RemainInTrackerAfterGitignoreChange() - { - var ignoredFile = CreateFile("secret.py", "API_KEY = 'secret'"); - var normalFile = CreateFile("app.ts", "export const app = {};"); - await TriggerFileChangeAsync(ignoredFile); - await TriggerFileChangeAsync(normalFile); - AssertFileInTracker(ignoredFile, true); - AssertFileInTracker(normalFile, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, "*.py\n"); - await Task.Delay(200); - - AssertFileInTracker(ignoredFile, false); - AssertFileInTracker(normalFile, true); - } - - [TestMethod] - public async Task EmptyGitignore_DoesNotRemoveTrackedFiles() - { - var file1 = CreateFile("test.ts", "export const test = 1;"); - await TriggerFileChangeAsync(file1); - AssertFileInTracker(file1, true); - - var gitignorePath = Path.Combine(_testRepoPath, ".gitignore"); - File.WriteAllText(gitignorePath, string.Empty); - await Task.Delay(200); - - AssertFileInTracker(file1, true); - } - - protected override void OnAfterSetup() - { - SetupWithGitignoreSupport(); - } - } - - [TestClass] - public class TrackerManagerTests - { - private TrackerManager _trackerManager; - - [TestInitialize] - public void Setup() - { - _trackerManager = new TrackerManager(); - } - - [TestMethod] - public void GetAllTrackedFiles_ReturnsEmptyList_WhenNoFilesTracked() - { - var files = _trackerManager.GetAllTrackedFiles(); - Assert.IsNotNull(files); - Assert.IsEmpty(files); - } - - [TestMethod] - public void GetAllTrackedFiles_ReturnsSingleFile_WhenOneFileTracked() - { - var filePath = @"C:\test\file.ts"; - _trackerManager.Add(filePath); - - var files = _trackerManager.GetAllTrackedFiles(); - Assert.HasCount(1, files); - CollectionAssert.Contains(files, filePath); - } - - [TestMethod] - public void GetAllTrackedFiles_ReturnsAllFiles_WhenMultipleFilesTracked() - { - var file1 = @"C:\test\file1.ts"; - var file2 = @"C:\test\file2.ts"; - var file3 = @"C:\test\file3.ts"; - - _trackerManager.Add(file1); - _trackerManager.Add(file2); - _trackerManager.Add(file3); - - var files = _trackerManager.GetAllTrackedFiles(); - Assert.HasCount(3, files); - CollectionAssert.Contains(files, file1); - CollectionAssert.Contains(files, file2); - CollectionAssert.Contains(files, file3); - } - - [TestMethod] - public void GetAllTrackedFiles_ReturnsSnapshot_NotLiveReference() - { - var file1 = @"C:\test\file1.ts"; - _trackerManager.Add(file1); - - var files1 = _trackerManager.GetAllTrackedFiles(); - var file2 = @"C:\test\file2.ts"; - _trackerManager.Add(file2); - var files2 = _trackerManager.GetAllTrackedFiles(); - - Assert.HasCount(1, files1); - Assert.HasCount(2, files2); - } - - [TestMethod] - public void GetAllTrackedFiles_DoesNotIncludeRemovedFiles() - { - var file1 = @"C:\test\file1.ts"; - var file2 = @"C:\test\file2.ts"; - - _trackerManager.Add(file1); - _trackerManager.Add(file2); - _trackerManager.Remove(file1); - - var files = _trackerManager.GetAllTrackedFiles(); - Assert.HasCount(1, files); - CollectionAssert.DoesNotContain(files, file1); - CollectionAssert.Contains(files, file2); - } - - [TestMethod] - public void GetAllTrackedFiles_HandlesAddingSameFileTwice() - { - var filePath = @"C:\test\file.ts"; - _trackerManager.Add(filePath); - _trackerManager.Add(filePath); - - var files = _trackerManager.GetAllTrackedFiles(); - Assert.HasCount(1, files); - } - } - - [TestClass] - public class GitIgnoreWatcherTests - { - private string _testDir; - - [TestInitialize] - public void Setup() - { - _testDir = Path.Combine(Path.GetTempPath(), $"gitignore-watcher-test-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testDir); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testDir)) - { - try - { - Directory.Delete(_testDir, true); - } - catch - { - } - } - } - - [TestMethod] - public async Task GitIgnoreWatcher_FiresEvent_WhenGitignoreCreated() - { - var eventFired = false; - var watcher = new GitIgnoreWatcher(_testDir, null); - watcher.GitIgnoreChanged += (sender, args) => eventFired = true; - - await Task.Delay(100); - - var gitignorePath = Path.Combine(_testDir, ".gitignore"); - File.WriteAllText(gitignorePath, "*.log\n"); - - var success = await WaitForConditionAsync(() => eventFired, 2000); - watcher.Dispose(); - - Assert.IsTrue(success, "GitIgnoreChanged event should have fired"); - } - - [TestMethod] - public async Task GitIgnoreWatcher_FiresEvent_WhenGitignoreModified() - { - var gitignorePath = Path.Combine(_testDir, ".gitignore"); - File.WriteAllText(gitignorePath, "*.log\n"); - - var eventFired = false; - var watcher = new GitIgnoreWatcher(_testDir, null); - watcher.GitIgnoreChanged += (sender, args) => eventFired = true; - - await Task.Delay(100); - - File.AppendAllText(gitignorePath, "*.tmp\n"); - - var success = await WaitForConditionAsync(() => eventFired, 2000); - watcher.Dispose(); - - Assert.IsTrue(success, "GitIgnoreChanged event should have fired"); - } - - [TestMethod] - public async Task GitIgnoreWatcher_FiresEvent_WhenGitignoreDeleted() - { - var gitignorePath = Path.Combine(_testDir, ".gitignore"); - File.WriteAllText(gitignorePath, "*.log\n"); - - var eventFired = false; - var watcher = new GitIgnoreWatcher(_testDir, null); - watcher.GitIgnoreChanged += (sender, args) => eventFired = true; - - await Task.Delay(100); - - File.Delete(gitignorePath); - - var success = await WaitForConditionAsync(() => eventFired, 2000); - watcher.Dispose(); - - Assert.IsTrue(success, "GitIgnoreChanged event should have fired"); - } - - [TestMethod] - public async Task GitIgnoreWatcher_FiresEvent_WhenGitignoreInSubdirectoryCreated() - { - var subDir = Path.Combine(_testDir, "src"); - Directory.CreateDirectory(subDir); - - var eventFired = false; - var watcher = new GitIgnoreWatcher(_testDir, null); - watcher.GitIgnoreChanged += (sender, args) => eventFired = true; - - await Task.Delay(100); - - var gitignorePath = Path.Combine(subDir, ".gitignore"); - File.WriteAllText(gitignorePath, "*.test.ts\n"); - - var success = await WaitForConditionAsync(() => eventFired, 2000); - watcher.Dispose(); - - Assert.IsTrue(success, "GitIgnoreChanged event should have fired for subdirectory .gitignore"); - } - - [TestMethod] - public void GitIgnoreWatcher_DisposesCleanly() - { - var watcher = new GitIgnoreWatcher(_testDir, null); - watcher.Dispose(); - watcher.Dispose(); - } - - [TestMethod] - public void GitIgnoreWatcher_HandlesNullLogger() - { - var watcher = new GitIgnoreWatcher(_testDir, null); - Assert.IsNotNull(watcher); - watcher.Dispose(); - } - - [TestMethod] - public void GitIgnoreWatcher_HandlesNonExistentDirectory() - { - var nonExistentDir = Path.Combine(_testDir, "nonexistent"); - var watcher = new GitIgnoreWatcher(nonExistentDir, null); - Assert.IsNotNull(watcher); - watcher.Dispose(); - } - - [TestMethod] - public void GitIgnoreWatcher_HandlesNullDirectory() - { - var watcher = new GitIgnoreWatcher(null, null); - Assert.IsNotNull(watcher); - watcher.Dispose(); - } - - [TestMethod] - public async Task GitIgnoreWatcher_ThrowingHandler_LogsErrorAndDoesNotCrash() - { - var mockLogger = new Mock(); - var watcher = new GitIgnoreWatcher(_testDir, mockLogger.Object); - watcher.GitIgnoreChanged += (sender, args) => throw new InvalidOperationException("handler error"); - - await Task.Delay(100); - - var gitignorePath = Path.Combine(_testDir, ".gitignore"); - File.WriteAllText(gitignorePath, "*.log\n"); - - await WaitForConditionAsync( - () => mockLogger.Invocations.Any(i => i.Method.Name == "Error"), - 2000); - watcher.Dispose(); - - mockLogger.Verify( - l => l.Error(It.Is(s => s.Contains("Error in GitIgnoreChanged handler")), It.IsAny()), - Times.AtLeastOnce); - } - - private async Task WaitForConditionAsync(Func condition, int timeoutMs = 5000, int pollIntervalMs = 100) - { - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - if (condition()) - { - return true; - } - - await Task.Delay(pollIntervalMs); - } - - return false; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs index c548acde..219238d5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs @@ -1,5 +1,7 @@ // Copyright (c) CodeScene. All rights reserved. +using System.Security.AccessControl; +using System.Security.Principal; using Codescene.VSExtension.Core.Util; namespace Codescene.VSExtension.Core.Tests @@ -164,6 +166,63 @@ public void ConvertToAbsolutePath_InvalidPath_FallsBackToPathCombine() Assert.AreEqual(Path.Combine(_tempDir, invalidRelative), result); } + [TestMethod] + public void FindNestedRepositories_MissingDirectory_ReturnsEmpty() + { + Assert.AreEqual(0, GitPathDiscovery.FindNestedRepositories(null).Count); + Assert.AreEqual(0, GitPathDiscovery.FindNestedRepositories(string.Empty).Count); + Assert.AreEqual(0, GitPathDiscovery.FindNestedRepositories(Path.Combine(_tempDir, "missing")).Count); + } + + [TestMethod] + public void FindNestedRepositories_SkipsHiddenChild() + { + Directory.CreateDirectory(Path.Combine(_workspaceDir, ".hidden")); + + Assert.AreEqual(0, GitPathDiscovery.FindNestedRepositories(_workspaceDir).Count); + } + + [TestMethod] + public void FindNestedRepositories_WhenListingFails_ReturnsEmpty() + { + var blocked = new DirectoryInfo(Path.Combine(_tempDir, "blocked")); + blocked.Create(); + var identity = WindowsIdentity.GetCurrent().User; + var rule = new FileSystemAccessRule( + identity, + FileSystemRights.ListDirectory, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Deny); + var security = blocked.GetAccessControl(); + security.AddAccessRule(rule); + blocked.SetAccessControl(security); + try + { + Assert.Throws(() => Directory.GetDirectories(blocked.FullName)); + Assert.AreEqual(0, GitPathDiscovery.FindNestedRepositories(blocked.FullName).Count); + } + finally + { + security.RemoveAccessRule(rule); + blocked.SetAccessControl(security); + } + } + + [TestMethod] + public void TryGetWorkingDirectory_NullPath_ReturnsNull() + { + Assert.IsNull(GitPathDiscovery.TryGetWorkingDirectory(null)); + } + + [TestMethod] + public void TryGetWorkingDirectory_PathOutsideRepository_ReturnsNull() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "file.cs"); + + Assert.IsNull(GitPathDiscovery.TryGetWorkingDirectory(path)); + } + [TestMethod] public void IsFileInWorkspace_FileExistsOutsideWorkspace_ReturnsFalse() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientRestartTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientRestartTests.cs new file mode 100644 index 00000000..be507a2f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientRestartTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.IO; +using System.Security.Cryptography; +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Moq; +using Nerdbank.Streams; +using StreamJsonRpc; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class IdeServerClientRestartTests + { + private string _tempFilePath; + private Mock _settings; + private Mock _logger; + private Mock _userSettings; + + [TestInitialize] + public void Setup() + { + _tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".exe"); + File.WriteAllText(_tempFilePath, "dummy-cli"); + _settings = new Mock(); + _logger = new Mock(); + _userSettings = new Mock(); + _settings.Setup(x => x.CliFileFullPath).Returns(_tempFilePath); + _settings.Setup(x => x.RequiredDevToolVersion).Returns("fixture-sha"); + _settings.Setup(x => x.RequiredCliBinarySha256).Returns(Sha256(_tempFilePath)); + _userSettings.Setup(x => x.ServerWorkerThreads).Returns(0); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_tempFilePath)) + { + File.Delete(_tempFilePath); + } + } + + [TestMethod] + public async Task RestartAsync_RestartsServerProcess() + { + var servers = new List<(JsonRpc Rpc, Stream Stream)>(); + var factory = new RestartableProcessFactory(servers); + using var client = new IdeServerClient( + _settings.Object, + _logger.Object, + factory, + _userSettings.Object, + TimeSpan.FromSeconds(5)); + await client.StartAsync(); + await client.RestartAsync(); + + Assert.AreEqual(2, factory.StartCount); + _logger.Verify(x => x.Info("[cs-ide] restarting"), Times.Once); + foreach (var (rpc, stream) in servers) + { + rpc.Dispose(); + stream.Dispose(); + } + } + + private static string Sha256(string path) + { + using var stream = File.OpenRead(path); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + + private sealed class RestartableProcessFactory : IIdeServerProcessFactory + { + private readonly List<(JsonRpc Rpc, Stream Stream)> _servers; + + public RestartableProcessFactory(List<(JsonRpc Rpc, Stream Stream)> servers) + { + _servers = servers; + } + + public int StartCount { get; private set; } + + public IIdeServerProcess Start(string fileName, string arguments) + { + StartCount++; + var (clientStream, serverStream) = FullDuplexStream.CreatePair(); + var process = new FakeIdeServerProcess(clientStream, clientStream); + var serverRpc = new JsonRpc(new HeaderDelimitedMessageHandler(serverStream, serverStream)); + var server = new FixtureServer(serverRpc); + serverRpc.AddLocalRpcTarget(server); + serverRpc.StartListening(); + _servers.Add((serverRpc, serverStream)); + _ = Task.Run(async () => + { + await Task.Yield(); + await serverRpc.NotifyWithParameterObjectAsync("cs-ide/start", new { sha = "fixture-sha", version = "fixture-version" }); + }); + return process; + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs new file mode 100644 index 00000000..fb014f81 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -0,0 +1,706 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.IO; +using System.Security.Cryptography; +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Exceptions; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; +using Nerdbank.Streams; +using Newtonsoft.Json.Linq; +using StreamJsonRpc; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class IdeServerClientTests + { + private string _tempFilePath; + private Mock _settings; + private Mock _logger; + private Mock _userSettings; + + [TestInitialize] + public void Setup() + { + _tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".exe"); + File.WriteAllText(_tempFilePath, "dummy-cli"); + _settings = new Mock(); + _logger = new Mock(); + _userSettings = new Mock(); + _settings.Setup(x => x.CliFileFullPath).Returns(_tempFilePath); + _settings.Setup(x => x.RequiredDevToolVersion).Returns("fixture-sha"); + _settings.Setup(x => x.RequiredCliBinarySha256).Returns(Sha256(_tempFilePath)); + _userSettings.Setup(x => x.ServerWorkerThreads).Returns(0); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_tempFilePath)) + { + File.Delete(_tempFilePath); + } + } + + [TestMethod] + public async Task StartAsync_FileDoesNotExist_ThrowsFileNotFoundException() + { + File.Delete(_tempFilePath); + using var client = CreateClient(new FakeProcessFactory(new FakeIdeServerProcess(Stream.Null, Stream.Null))); + + var exception = await Assert.ThrowsAsync(() => client.StartAsync()); + + Assert.Contains("CodeScene CLI executable not found", exception.Message); + } + + [TestMethod] + public async Task StartAsync_WaitsForStartNotificationAndLaunchesServerCommand() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + using var client = harness.Client; + + var metadata = await client.StartAsync(); + + Assert.AreEqual("fixture-sha", metadata.Sha); + Assert.AreEqual("fixture-version", metadata.Version); + Assert.StartsWith("server --threads ", harness.Factory.LastArguments); + } + + [TestMethod] + public async Task StartAsync_WhenShaDoesNotMatch_Throws() + { + _settings.Setup(x => x.RequiredDevToolVersion).Returns("other-sha"); + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + + var exception = await Assert.ThrowsAsync(() => harness.Client.StartAsync()); + + Assert.Contains("version mismatch", exception.Message); + } + + [TestMethod] + public async Task StartAsync_LogsCliStderrToCodeSceneLog() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + harness.Process.RaiseError("nrepl started on 7888"); + + _logger.Verify(x => x.Info("[cs-ide] nrepl started on 7888"), Times.Once); + } + + [TestMethod] + public async Task DeviceIdAsync_ReadsCamelCaseOrKebabCase() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var deviceId = await harness.Client.DeviceIdAsync(); + + Assert.AreEqual("device-42", deviceId); + } + + [TestMethod] + public async Task RefactorAsync_WhenCancelled_DoesNotThrowDevtoolsException() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.CancelAfter(50); + + await Assert.ThrowsAsync(() => harness.Client.RefactorAsync( + new RefactorPostRequestModel { Token = "delay-cancel" }, + cancellation.Token)); + } + + [TestMethod] + public async Task SendRequestAsync_WhenCancelled_LogsDebugNotError() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync(() => harness.Client.RefactorAsync( + new RefactorPostRequestModel { Token = "delay-cancel" }, + cancellation.Token)); + + _logger.Verify( + x => x.Debug(It.Is(message => message.Contains("request canceled"))), + Times.AtLeastOnce); + _logger.Verify( + x => x.Error(It.Is(message => message.Contains("request failed")), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task RefactoringRpcEndpoints_NormalizeResponses() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var functions = await harness.Client.FnsToRefactorAsync(new FnsToRefactorCodeSmellRequestModel + { + FileName = "a.cs", + FileContent = "class A {}", + }); + var refactoring = await harness.Client.RefactorAsync(new RefactorPostRequestModel { Token = "token" }); + + Assert.AreEqual("Function", functions[0].Name); + Assert.AreEqual("refactored", refactoring.Code); + Assert.AreEqual("trace-1", refactoring.TraceId); + } + + [TestMethod] + public async Task PreflightAsync_ReturnsResponse() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var response = await harness.Client.PreflightAsync(force: true); + + Assert.AreEqual(1m, response.Version); + Assert.Contains("cs", response.FileTypes); + } + + [TestMethod] + public async Task CheckRulesAsync_WhenRpcFails_ThrowsDevtoolsExceptionAndLogsError() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var exception = await Assert.ThrowsAsync(() => harness.Client.CheckRulesAsync("C:/repo", "fail-401")); + + Assert.AreEqual(401, exception.Status); + _logger.Verify( + x => x.Error(It.Is(message => message.Contains("request failed")), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task StartAsync_WhenProcessExits_RaisesServerError() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var errorReceived = new TaskCompletionSource(); + harness.Client.ServerError += (_, error) => errorReceived.TrySetResult(error); + + harness.Process.Kill(); + + var error = await WaitForAsync(errorReceived.Task); + Assert.Contains("exited", error.Message); + } + + [TestMethod] + public async Task OutboundNotification_WhenServerStops_LogsError() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + harness.Process.Kill(); + await Task.Delay(200); + + harness.Client.WatchFiles("C:/repo", new[] { "src" }); + await Task.Delay(500); + + _logger.Verify( + x => x.Error(It.Is(message => message.Contains("notification failed")), It.IsAny()), + Times.AtLeastOnce); + } + + [TestMethod] + public async Task RulesRpcEndpoints_NormalizeResponses() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + await harness.Client.TelemetryAsync(new { eventName = "test" }); + var template = await harness.Client.CodeHealthRulesTemplateAsync(); + var rules = await harness.Client.CheckRulesAsync("C:/repo", "src/a.cs"); + + Assert.AreEqual("rules", template); + Assert.AreEqual("ok", rules.Result); + Assert.IsFalse(rules.Failed); + } + + [TestMethod] + public async Task GetWatchInventoryAsync_NormalizesResponse() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var inventory = await harness.Client.GetWatchInventoryAsync("C:/repo"); + + Assert.AreEqual("C:/repo", inventory.RepoRoot); + Assert.AreEqual("src/a.cs", inventory.Files[0]); + } + + [TestMethod] + public async Task StopWatchFiles_BeforeAndAfterStart_DoesNotThrow() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + + harness.Client.StopWatchFiles("C:/repo", new[] { "src" }); + await harness.Client.StartAsync(); + harness.Client.StopWatchFiles("C:/repo", new[] { "src" }); + + var parameters = await WaitForAsync(harness.Server.StopWatchReceived.Task); + Assert.AreEqual("C:/repo", (string)parameters["repo-root"]); + Assert.AreEqual("src", (string)parameters["relative-paths"][0]); + } + + [TestMethod] + public async Task DeltaReview_RaisesDeltaNotification() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var deltaReceived = new TaskCompletionSource(); + var queueCount = 0; + harness.Client.DeltaReceived += (_, delta) => deltaReceived.TrySetResult(delta); + harness.Client.QueueChanged += (_, __) => queueCount++; + + await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/deltaReview", new + { + id = "delta-1", + path = "src/a.cs", + repoRoot = "C:/repo", + result = new + { + oldScore = 8.0, + newScore = 9.0, + scoreChange = 1.0, + oldGitBlobSha = "old-sha", + newGitBlobSha = "new-sha", + }, + }); + + var delta = await WaitForAsync(deltaReceived.Task); + await Task.Delay(100); + Assert.AreEqual( + ("delta-1", "src/a.cs", "C:/repo", 1.0m, "old-sha", "new-sha"), + (delta.Id, delta.Path, delta.RepoRoot, delta.Result.ScoreChange, delta.Result.OldGitBlobSha, delta.Result.NewGitBlobSha)); + Assert.AreEqual(0, queueCount); + } + + [TestMethod] + public async Task InboundNotifications_WithMissingIdentity_AreIgnored() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var reviewCount = 0; + var deltaCount = 0; + var failureCount = 0; + var inventoryCount = 0; + harness.Client.ReviewReceived += (_, __) => reviewCount++; + harness.Client.DeltaReceived += (_, __) => deltaCount++; + harness.Client.ReviewFailed += (_, __) => failureCount++; + harness.Client.WatchInventoryChanged += (_, __) => inventoryCount++; + + await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/fileReview", new { path = "a.cs" }); + await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/deltaReview", new { repoRoot = "C:/repo" }); + await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/reviewFailed", new { path = "a.cs" }); + await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/watchInventoryChanged", new { files = Array.Empty() }); + await Task.Delay(100); + + Assert.AreEqual((0, 0, 0, 0), (reviewCount, deltaCount, failureCount, inventoryCount)); + } + + [TestMethod] + public async Task WatchFiles_RaisesInventoryAndReviewNotifications() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var inventoryReceived = new TaskCompletionSource(); + var reviewReceived = new TaskCompletionSource(); + harness.Client.WatchInventoryChanged += (_, inventory) => inventoryReceived.TrySetResult(inventory); + harness.Client.ReviewReceived += (_, review) => reviewReceived.TrySetResult(review); + + harness.Client.WatchFiles("C:/repo", new[] { "src" }); + + var inventory = await WaitForAsync(inventoryReceived.Task); + var review = await WaitForAsync(reviewReceived.Task); + Assert.AreEqual( + ("C:/repo", "watched.ts", null, "watched.ts"), + (inventory.RepoRoot, inventory.Files[0], review.Id, review.Path)); + } + + [TestMethod] + public async Task ReviewFiles_EchoesIdAndQueue() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var reviewReceived = new TaskCompletionSource(); + var queueReceived = new TaskCompletionSource(); + harness.Client.ReviewReceived += (_, review) => reviewReceived.TrySetResult(review); + harness.Client.QueueChanged += (_, queue) => queueReceived.TrySetResult(queue); + + var files = new[] { new ReviewFile { Id = "req-1", RelPath = "src/a.ts", Content = "hello" } }; + harness.Client.ReviewFiles("C:/repo", files); + + var review = await WaitForAsync(reviewReceived.Task); + var queue = await WaitForAsync(queueReceived.Task); + Assert.AreEqual( + ("req-1", "src/a.ts", GitBlobSha.FromUtf8("hello"), 2, 1, "b.ts"), + (review.Id, review.Path, review.Result.GitBlobSha, queue.Count, queue.Done, queue.Files[0])); + Assert.AreEqual("c.ts", queue.Files[1]); + } + + [TestMethod] + public async Task ReviewFailed_TreatsNullIdAsMissing() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var failed = new TaskCompletionSource(); + harness.Client.ReviewFailed += (_, notification) => failed.TrySetResult(notification); + + var files = new[] { new ReviewFile { RelPath = "src/a.ts", Content = "fail" } }; + harness.Client.ReviewFiles("C:/repo", files); + + var notification = await WaitForAsync(failed.Task); + Assert.IsNull(notification.Id); + Assert.AreEqual("src/a.ts", notification.Path); + } + + [TestMethod] + public async Task NativeProcess_CapturesRedirectedStderr() + { + var factory = new NativeIdeServerProcessFactory(); + using (var streamProcess = (NativeIdeServerProcess)factory.Start("cmd.exe", "/d /c exit")) + { + Assert.IsTrue( + streamProcess.StandardInput != null && + streamProcess.StandardOutput != null && + streamProcess.StandardError != null && + streamProcess.Id > 0 && + streamProcess.StderrSnapshot == string.Empty); + } + + var errorReceived = new TaskCompletionSource(); + using (var errorProcess = (NativeIdeServerProcess)factory.Start( + "cmd.exe", + "/d /c \"echo server-error 1>&2\"")) + { + errorProcess.ErrorDataReceived += (_, args) => errorReceived.TrySetResult(args.Data); + errorProcess.BeginErrorReadLine(); + + var error = await WaitForAsync(errorReceived.Task); + Assert.IsTrue(error.Contains("server-error") && errorProcess.StderrSnapshot.Contains("server-error")); + } + } + + private IdeServerClient CreateClient(IIdeServerProcessFactory factory) + { + return new IdeServerClient( + _settings.Object, + _logger.Object, + factory, + _userSettings.Object, + TimeSpan.FromSeconds(2)); + } + + private async Task WaitForAsync(Task task) + { + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + if (completed != task) + { + throw new TimeoutException("Timed out waiting for server notification."); + } + + return await task; + } + + private string Sha256(string path) + { + using var stream = File.OpenRead(path); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + internal sealed class IdeServerTestHarness : IDisposable + { + private IdeServerTestHarness( + IdeServerClient client, + FakeProcessFactory factory, + FakeIdeServerProcess process, + JsonRpc serverRpc, + Stream serverStream, + FixtureServer server) + { + Client = client; + Factory = factory; + Process = process; + ServerRpc = serverRpc; + ServerStream = serverStream; + Server = server; + } + + public IdeServerClient Client { get; } + + public FakeProcessFactory Factory { get; } + + public FakeIdeServerProcess Process { get; } + + public JsonRpc ServerRpc { get; } + + public Stream ServerStream { get; } + + public FixtureServer Server { get; } + + public static async Task StartAsync( + Mock settings, + Mock logger, + Mock userSettings) + { + var (clientStream, serverStream) = FullDuplexStream.CreatePair(); + var process = new FakeIdeServerProcess(clientStream, clientStream); + var factory = new FakeProcessFactory(process); + var serverRpc = new JsonRpc(new HeaderDelimitedMessageHandler(serverStream, serverStream)); + var server = new FixtureServer(serverRpc); + serverRpc.AddLocalRpcTarget(server); + serverRpc.StartListening(); + var client = new IdeServerClient(settings.Object, logger.Object, factory, userSettings.Object, TimeSpan.FromSeconds(5)); + var harness = new IdeServerTestHarness(client, factory, process, serverRpc, serverStream, server); + _ = Task.Run(async () => + { + await Task.Yield(); + await serverRpc.NotifyWithParameterObjectAsync("cs-ide/start", new { sha = "fixture-sha", version = "fixture-version" }); + }); + return harness; + } + + public void Dispose() + { + Client.Dispose(); + ServerRpc.Dispose(); + ServerStream.Dispose(); + } + } + + internal sealed class FixtureServer + { + private readonly JsonRpc _rpc; + + public FixtureServer(JsonRpc rpc) + { + _rpc = rpc; + } + + public TaskCompletionSource StopWatchReceived { get; } = new TaskCompletionSource(); + + [JsonRpcMethod("cs-ide/review", UseSingleObjectParameterDeserialization = true)] + public JObject Review(JObject request) + { + if (string.Equals((string)request["file-content"], "trigger-http-401", StringComparison.Ordinal)) + { + throw new LocalRpcException("Request failed [401]"); + } + + return JObject.Parse(@"{""fileLevelCodeSmells"":[],""functionLevelCodeSmells"":[],""rawScore"":""raw"",""score"":9.68,""gitBlobSha"":""review-sha""}"); + } + + [JsonRpcMethod("cs-ide/preflight", UseSingleObjectParameterDeserialization = true)] + public JObject Preflight(JObject request) + { + return JObject.Parse(@"{""version"":1,""file-types"":[""cs""],""language-common"":{""code-smells"":[""smell""],""max-input-loc"":1000}}"); + } + + [JsonRpcMethod("cs-ide/device-id", UseSingleObjectParameterDeserialization = true)] + public JObject DeviceId(JObject request) + { + return JObject.Parse(@"{""deviceId"":""device-42""}"); + } + + [JsonRpcMethod("cs-ide/fns-to-refactor", UseSingleObjectParameterDeserialization = true)] + public JArray FnsToRefactor(JObject request) + { + return JArray.Parse(@"[{""name"":""Function"",""nippyB64"":""nippy""}]"); + } + + [JsonRpcMethod("cs-ide/refactor", UseSingleObjectParameterDeserialization = true)] + public async Task Refactor(JObject request, CancellationToken cancellationToken) + { + if (string.Equals((string)request["token"], "delay-cancel", StringComparison.Ordinal)) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + + return JObject.Parse(@"{""code"":""refactored"",""traceId"":""trace-1""}"); + } + + [JsonRpcMethod("cs-ide/telemetry", UseSingleObjectParameterDeserialization = true)] + public object? Telemetry(JObject request) + { + return null; + } + + [JsonRpcMethod("cs-ide/code-health-rules-template", UseSingleObjectParameterDeserialization = true)] + public JObject CodeHealthRulesTemplate(JObject request) + { + return JObject.Parse(@"{""template"":""rules""}"); + } + + [JsonRpcMethod("cs-ide/check-rules", UseSingleObjectParameterDeserialization = true)] + public JObject CheckRules(JObject request) + { + if (string.Equals((string)request["path"], "fail-401", StringComparison.Ordinal)) + { + throw new LocalRpcException("Request failed [401]"); + } + + return JObject.Parse(@"{""result"":""ok"",""failed"":false,""parsingErrors"":[]}"); + } + + [JsonRpcMethod("cs-ide/getWatchInventory", UseSingleObjectParameterDeserialization = true)] + public JObject GetWatchInventory(JObject request) + { + return JObject.Parse(@"{""files"":[""src/a.cs""]}"); + } + + [JsonRpcMethod("cs-ide/stopWatchFiles", UseSingleObjectParameterDeserialization = true)] + public void StopWatchFiles(JObject parameters) + { + StopWatchReceived.TrySetResult(parameters); + } + + [JsonRpcMethod("cs-ide/watchFiles", UseSingleObjectParameterDeserialization = true)] + public async Task WatchFiles(JObject parameters) + { + var repoRoot = (string)parameters["repo-root"]; + await _rpc.NotifyWithParameterObjectAsync("cs-ide/watchInventoryChanged", new { repoRoot, files = new[] { "watched.ts" } }); + await _rpc.NotifyWithParameterObjectAsync("cs-ide/fileReview", new + { + path = "watched.ts", + repoRoot, + result = new { fileLevelCodeSmells = Array.Empty(), functionLevelCodeSmells = Array.Empty(), rawScore = "raw", score = 9.68, gitBlobSha = "watch-sha" }, + }); + } + + [JsonRpcMethod("cs-ide/reviewFiles", UseSingleObjectParameterDeserialization = true)] + public async Task ReviewFiles(JObject parameters) + { + var repoRoot = (string)parameters["repo-root"]; + foreach (var file in (JArray)parameters["files"]) + { + var content = (string)file["content"] ?? string.Empty; + var relPath = (string)file["rel-path"]; + var id = file["id"]?.Type == JTokenType.Null ? null : (string)file["id"]; + if (content == "fail") + { + await _rpc.NotifyWithParameterObjectAsync("cs-ide/reviewProgress", new + { + count = 0, + done = 3, + files = Array.Empty(), + }); + var failed = new JObject + { + ["id"] = null, + ["path"] = relPath, + ["repoRoot"] = repoRoot, + ["message"] = "fixture review failed", + }; + await _rpc.NotifyAsync("cs-ide/reviewFailed", failed); + continue; + } + + await _rpc.NotifyWithParameterObjectAsync("cs-ide/reviewProgress", new + { + count = 2, + done = 1, + files = new[] { "b.ts", "c.ts" }, + }); + var payload = new JObject + { + ["path"] = relPath, + ["repoRoot"] = repoRoot, + ["result"] = new JObject + { + ["rawScore"] = "raw", + ["score"] = 9.68, + ["gitBlobSha"] = GitBlobSha.FromUtf8(content), + }, + }; + if (!string.IsNullOrEmpty(id)) + { + payload["id"] = id; + } + + await _rpc.NotifyAsync("cs-ide/fileReview", payload); + } + } + } + + internal sealed class FakeProcessFactory : IIdeServerProcessFactory + { + private readonly IIdeServerProcess _process; + + public FakeProcessFactory(IIdeServerProcess process) + { + _process = process; + } + + public string LastFileName { get; private set; } + + public string LastArguments { get; private set; } + + public int StartCount { get; private set; } + + public IIdeServerProcess Start(string fileName, string arguments) + { + LastFileName = fileName; + LastArguments = arguments; + StartCount++; + return _process; + } + } + + internal sealed class FakeIdeServerProcess : IIdeServerProcess + { + public FakeIdeServerProcess(Stream input, Stream output) + { + StandardInput = input; + StandardOutput = output; + StandardError = Stream.Null; + } + + public event EventHandler Exited; + + public event EventHandler ErrorDataReceived; + + public Stream StandardInput { get; } + + public Stream StandardOutput { get; } + + public Stream StandardError { get; } + + public int Id => 1; + + public bool HasExited { get; private set; } + + public void BeginErrorReadLine() + { + } + + public void RaiseError(string data) + { + ErrorDataReceived?.Invoke(this, new IdeServerErrorDataEventArgs(data)); + } + + public void Kill() + { + HasExited = true; + Exited?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + Kill(); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerLogTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerLogTests.cs new file mode 100644 index 00000000..62c691d7 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerLogTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Models; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Models.Cli.Telemetry; +using Newtonsoft.Json.Linq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class IdeServerLogTests + { + [TestMethod] + public void SummarizeRequest_UnknownMethod_ReturnsEmpty() + { + Assert.AreEqual(string.Empty, IdeServerLog.SummarizeRequest("cs-ide/unknown", new object())); + } + + [TestMethod] + public void SummarizeResponse_NullResult_ReturnsEmpty() + { + Assert.AreEqual(string.Empty, IdeServerLog.SummarizeResponse("cs-ide/device-id", null)); + } + + [TestMethod] + public void ReviewSummary_NullResult_ReturnsEmpty() + { + Assert.AreEqual(string.Empty, IdeServerLog.ReviewSummary(null)); + } + + [TestMethod] + public void ReviewSummary_IncludesFields() + { + var summary = IdeServerLog.ReviewSummary(new CliReviewModel + { + Score = 9.5f, + FileLevelCodeSmells = new List { new CliCodeSmellModel() }, + FunctionLevelCodeSmells = new List(), + GitBlobSha = "sha-1", + CodeHealthRulesError = new CliCodeHealthRulesErrorModel(), + }); + + Assert.Contains("score=", summary); + Assert.Contains("fileSmells=1", summary); + Assert.Contains("hasRulesError=True", summary); + } + + [TestMethod] + public void DeltaSummary_NullResult_ReportsNull() + { + Assert.Contains("result=null", IdeServerLog.DeltaSummary(null)); + } + + [TestMethod] + public void DeltaSummary_IncludesScores() + { + var summary = IdeServerLog.DeltaSummary(new DeltaResponseModel + { + OldScore = 8, + NewScore = 9, + ScoreChange = 1, + FileLevelFindings = Array.Empty(), + FunctionLevelFindings = Array.Empty(), + }); + + Assert.Contains("oldScore=8", summary); + Assert.Contains("newScore=9", summary); + } + + [TestMethod] + public void SummarizeRequest_PreflightCheckRulesAndInventory() + { + Assert.Contains("force=True", IdeServerLog.SummarizeRequest("cs-ide/preflight", new PreflightRequestParams { Force = true })); + Assert.Contains("repoRoot=", IdeServerLog.SummarizeRequest( + "cs-ide/check-rules", + new CheckRulesRequestParams { RepoRoot = "C:/repo", Path = "a.cs" })); + Assert.Contains("repoRoot=", IdeServerLog.SummarizeRequest( + "cs-ide/getWatchInventory", + new WatchInventoryRequestParams { RepoRoot = "C:/repo" })); + } + + [TestMethod] + public void SummarizeRequest_FnsAndRefactorFormats() + { + var smells = IdeServerLog.SummarizeRequest( + "cs-ide/fns-to-refactor", + new FnsToRefactorCodeSmellRequestModel + { + FileName = "a.cs", + CodeSmells = new List { new CliCodeSmellModel() }, + }); + Assert.Contains("input=code-smells", smells); + Assert.Contains("smellCount=1", smells); + + var delta = IdeServerLog.SummarizeRequest( + "cs-ide/fns-to-refactor", + new FnsToRefactorDeltaRequestModel + { + FileName = "a.cs", + DeltaResult = new DeltaResponseModel(), + }); + Assert.Contains("input=delta-result", delta); + + var refactor = IdeServerLog.SummarizeRequest( + "cs-ide/refactor", + new RefactorPostRequestModel { FnToRefactorNippyB64 = "nippy", SkipCache = true }); + Assert.Contains("format=nippy-b64", refactor); + Assert.Contains("skipCache=True", refactor); + } + + [TestMethod] + public void SummarizeRequest_TelemetryUsesEventName() + { + var summary = IdeServerLog.SummarizeRequest( + "cs-ide/telemetry", + new TelemetryRequestParams { Event = new TelemetryEvent { EventName = "opened" } }); + Assert.Contains("eventName=opened", summary); + + var jobjectSummary = IdeServerLog.SummarizeRequest( + "cs-ide/telemetry", + new TelemetryRequestParams { Event = JObject.Parse(@"{""event-name"":""saved""}") }); + Assert.Contains("eventName=saved", jobjectSummary); + } + + [TestMethod] + public void SummarizeResponse_PreflightRefactorFnsAndRules() + { + var preflight = IdeServerLog.SummarizeResponse( + "cs-ide/preflight", + new PreFlightResponseModel + { + Version = 2, + FileTypes = new[] { "cs" }, + LanguageCommon = new RefactorSupportModel + { + CodeSmells = new[] { "smell" }, + MaxInputLoc = 1000, + }, + }); + Assert.Contains("version=2", preflight); + + var functions = IdeServerLog.SummarizeResponse( + "cs-ide/fns-to-refactor", + new List + { + new FnToRefactorModel + { + Name = "Fn", + Range = new Codescene.VSExtension.Core.Models.Cli.CliRangeModel { StartLine = 1, StartColumn = 2, EndLine = 3, EndColumn = 4 }, + RefactoringTargets = new[] { new RefactoringTargetModel { Category = "complexity" } }, + }, + new FnToRefactorModel(), + }); + Assert.Contains("count=2", functions); + + var refactor = IdeServerLog.SummarizeResponse( + "cs-ide/refactor", + new RefactorResponseModel + { + TraceId = "trace", + Confidence = new ConfidenceModel { Level = 3 }, + Metadata = new MetadataModel { Cached = true }, + Reasons = new[] { new ReasonModel { Summary = "reason" } }, + RefactoringProperties = new RefactoringPropertiesModel { AddedCodeSmells = new[] { "a" }, RemovedCodeSmells = new[] { "b" } }, + }); + Assert.Contains("traceId=trace", refactor); + + var rules = IdeServerLog.SummarizeResponse( + "cs-ide/check-rules", + new CheckRulesResponse { Failed = true, ParsingErrors = new List { "err" } }); + Assert.Contains("failed=True", rules); + + var inventory = IdeServerLog.SummarizeResponse( + "cs-ide/getWatchInventory", + new WatchInventory { RepoRoot = "C:/repo", Files = new List { "a.cs" } }); + Assert.Contains("count=1", inventory); + } + + [TestMethod] + public void SendingAndReceived_IncludeSummarySuffix() + { + Assert.StartsWith("[cs-ide] sending id=1 method=cs-ide/preflight", IdeServerLog.Sending(1, "cs-ide/preflight", "force=True")); + Assert.Contains("force=True", IdeServerLog.Sending(1, "cs-ide/preflight", "force=True")); + Assert.StartsWith("[cs-ide] received id=2 method=cs-ide/device-id", IdeServerLog.Received(2, "cs-ide/device-id", 12, string.Empty)); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchMergeBaseSelectorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchMergeBaseSelectorTests.cs deleted file mode 100644 index 215360a2..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchMergeBaseSelectorTests.cs +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class MainBranchMergeBaseSelectorTests - { - private string _testRepoPath; - - [TestInitialize] - public void Setup() - { - MainBranchNames.ClearDefaultBranchCache(); - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-merge-base-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testRepoPath)) - { - try - { - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - [TestMethod] - public void FindClosest_WithNullRepo_ReturnsNull() - { - var result = MainBranchMergeBaseSelector.FindClosest(null); - - Assert.IsNull(result, "Should return null for null repository"); - } - - [TestMethod] - public void FindClosest_WithEmptyRepo_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNull(result, "Should return null when repo has no HEAD tip"); - } - } - - [TestMethod] - public void FindClosest_WhenDefaultBranchExists_UsesDefaultBranchForMergeBase() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNotNull(result, "Should find a merge base commit"); - } - } - - [TestMethod] - public void FindClosest_WhenDefaultBranchYieldsMergeBase_ReturnsEarlyWithoutCheckingAllBranches() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - - var initialCommitSha = GetHeadSha(); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNotNull(result, "Should find a merge base"); - Assert.AreEqual(initialCommitSha, result.Sha, "Should return the merge base from default branch"); - } - } - - [TestMethod] - public void FindClosest_WhenDefaultBranchHasNoResolvableRef_DoesNotFallBackToAllBranches() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m master"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/HEAD", "refs/remotes/origin/missing-main"); - } - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNull(result, "Should not fall back to master when configured default branch is missing"); - } - } - - [TestMethod] - public void FindClosest_WhenDefaultBranchIsMain_DoesNotFallBackToMaster() - { - CommitFile("README.md", "# Master", "Initial on master"); - ExecGit("branch -m master"); - CommitFile("master-only.cs", "class M {}", "Master commit"); - - ExecGit("checkout --orphan main"); - var mainFile = Path.Combine(_testRepoPath, "main.md"); - File.WriteAllText(mainFile, "# Main"); - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, "main.md"); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit("Initial on main", signature, signature); - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout master"); - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var selection = MainBranchMergeBaseSelector.Select(repo); - - Assert.IsNull(selection.Commit, "Should not use master when origin/HEAD default is main"); - } - } - - [TestMethod] - public void Select_WhenDefaultBranchYieldsMergeBase_ReportsBaselineBranchName() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var selection = MainBranchMergeBaseSelector.Select(repo); - - Assert.IsNotNull(selection.Commit, "Should find a merge base"); - Assert.AreEqual("main", selection.BaselineBranchName, "Should report the default baseline branch"); - } - } - - [TestMethod] - public void FindClosest_WhenNoDefaultBranch_FallsBackToAllBranches() - { - CommitFile("README.md", "# Test", "Initial commit on main"); - ExecGit("branch -m main"); - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNotNull(result, "Should find merge base via fallback to MainBranchNames.All"); - } - } - - [TestMethod] - public void FindClosest_WhenOnMainBranch_DoesNotUseSelfAsMergeBase() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNull(result, "Should return null when on main branch with no other candidates"); - } - } - - [TestMethod] - public void FindClosest_WithMultipleCandidates_ReturnsClosestReachable() - { - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - - CommitFile("file1.cs", "class A {}", "Second commit on main"); - - ExecGit("checkout -b develop"); - CommitFile("develop.cs", "class Develop {}", "Commit on develop"); - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "class Feature {}", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchMergeBaseSelector.FindClosest(repo); - - Assert.IsNotNull(result, "Should find a merge base from available candidates"); - } - } - - private void CommitFile(string filename, string content, string message) - { - var filePath = Path.Combine(_testRepoPath, filename); - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - { - Directory.CreateDirectory(dir); - } - - File.WriteAllText(filePath, content); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, filename); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit(message, signature, signature); - } - } - - private void ExecGit(string args) - { - var psi = new ProcessStartInfo - { - FileName = "git", - Arguments = args, - WorkingDirectory = _testRepoPath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using (var process = Process.Start(psi)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"Git command failed: {args}\n{error}"); - } - } - } - - private string GetHeadSha() - { - using (var repo = new Repository(_testRepoPath)) - { - return repo.Head.Tip.Sha; - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchNamesTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchNamesTests.cs deleted file mode 100644 index c0586967..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchNamesTests.cs +++ /dev/null @@ -1,497 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class MainBranchNamesTests - { - private string _testRepoPath; - - [TestInitialize] - public void Setup() - { - MainBranchNames.ClearDefaultBranchCache(); - - _testRepoPath = Path.Combine(Path.GetTempPath(), $"test-main-branch-{Guid.NewGuid()}"); - Directory.CreateDirectory(_testRepoPath); - Repository.Init(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Config.Set("user.email", "test@example.com"); - repo.Config.Set("user.name", "Test User"); - } - - CommitFile("README.md", "# Test", "Initial commit"); - ExecGit("branch -m main"); - } - - [TestCleanup] - public void Cleanup() - { - if (Directory.Exists(_testRepoPath)) - { - try - { - Directory.Delete(_testRepoPath, true); - } - catch - { - } - } - } - - [TestMethod] - public void GetDefaultBranch_WithoutOriginHead_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.IsNull(result, "Should return null when no origin/HEAD exists"); - } - } - - [TestMethod] - public void GetDefaultBranch_WithOriginHead_ReturnsDefaultBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.AreEqual("main", result, "Should return 'main' when origin/HEAD points to main"); - } - } - - [TestMethod] - public void GetDefaultBranch_WithNullRepo_ReturnsNull() - { - var result = MainBranchNames.GetDefaultBranch(null); - - Assert.IsNull(result, "Should return null for null repository"); - } - - [TestMethod] - public void IsMainBranch_WithRepo_WhenDefaultIsMain_OnlyMainIsMainBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - repo.CreateBranch("master"); - } - - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "main"), "main should be recognized as main branch"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "master"), "master should NOT be main when origin/HEAD points to main"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "develop"), "develop should NOT be main when origin/HEAD points to main"); - } - } - - [TestMethod] - public void IsMainBranch_WithRepo_WhenDefaultIsMaster_OnlyMasterIsMainBranch() - { - ExecGit("branch -m master"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/master", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/master"]); - repo.CreateBranch("main"); - } - - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "master"), "master should be recognized as main branch"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "main"), "main should NOT be main when origin/HEAD points to master"); - } - } - - [TestMethod] - public void IsMainBranch_WithRepo_WithoutOriginHead_FallsBackToStaticList() - { - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "main"), "main should be in fallback list"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "master"), "master should be in fallback list"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "develop"), "develop should be in fallback list"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "trunk"), "trunk should be in fallback list"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "feature-branch"), "feature-branch should not be main"); - } - } - - [TestMethod] - public void IsMainBranch_WithRepo_IsCaseInsensitive() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "main"), "main should match"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "Main"), "Main should match (case-insensitive)"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "MAIN"), "MAIN should match (case-insensitive)"); - } - } - - [TestMethod] - public void IsMainBranch_WithRepo_WithNullBranchName_ReturnsFalse() - { - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, null), "Should return false for null branch name"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, string.Empty), "Should return false for empty branch name"); - } - } - - [TestMethod] - public void IsMainBranch_WithoutRepo_UsesStaticListOnly() - { - Assert.IsTrue(MainBranchNames.IsMainBranch("main"), "main should be recognized"); - Assert.IsTrue(MainBranchNames.IsMainBranch("master"), "master should be recognized"); - Assert.IsTrue(MainBranchNames.IsMainBranch("develop"), "develop should be recognized"); - Assert.IsFalse(MainBranchNames.IsMainBranch("feature"), "feature should not be recognized"); - } - - [TestMethod] - public void IsMainBranch_WhenOriginHeadPointsToMain_AndOnMasterBranch_MainStillRecognizedAsMainBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - repo.CreateBranch("master"); - } - - ExecGit("checkout master"); - - using (var repo = new Repository(_testRepoPath)) - { - Assert.AreEqual("main", MainBranchNames.GetDefaultBranch(repo), "Default branch should still be main"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "main"), "main should still be the main branch"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "master"), "master should NOT be main even when checked out"); - } - } - - [TestMethod] - public void GetDefaultBranch_WithCustomDefaultBranch_ReturnsCustomName() - { - ExecGit("branch -m custom-main"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/custom-main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/custom-main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.AreEqual("custom-main", result, "Should return custom default branch name"); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "custom-main"), "Custom branch should be recognized as main"); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "main"), "main should NOT be main when origin/HEAD points elsewhere"); - } - } - - [TestMethod] - public void GetDefaultBranch_WhenTargetDoesNotStartWithExpectedPrefix_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", "refs/heads/main", true); - } - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.IsNull(result, "Should return null when target doesn't start with refs/remotes/origin/"); - } - } - - [TestMethod] - public void GetDefaultBranch_WithConfigBaselineBranch_ReturnsConfiguredBranch() - { - WriteConfig("{\"baseline_branch\":\"release\"}"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.AreEqual("release", result); - Assert.IsTrue(MainBranchNames.IsMainBranch(repo, "release")); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "main")); - } - } - - [TestMethod] - public void GetDefaultBranch_ConfigOverridesOriginHead() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - WriteConfig("{\"baseline_branch\":\"develop\"}"); - - using (var repo = new Repository(_testRepoPath)) - { - Assert.AreEqual("develop", MainBranchNames.GetDefaultBranch(repo)); - Assert.IsFalse(MainBranchNames.IsMainBranch(repo, "main")); - } - } - - [TestMethod] - public void GetDefaultBranch_InvalidConfig_FallsBackToOriginHead() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - WriteConfig("not json"); - - using (var repo = new Repository(_testRepoPath)) - { - Assert.AreEqual("main", MainBranchNames.GetDefaultBranch(repo)); - } - } - - [TestMethod] - public void GetDefaultBranch_WhenOriginHeadTargetIsEmpty_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - - var originHeadPath = Path.Combine(_testRepoPath, ".git", "refs", "remotes", "origin", "HEAD"); - File.WriteAllText(originHeadPath, "ref: \n"); - - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.IsNull(result); - } - } - - [TestMethod] - public void GetDefaultBranch_WhenExceptionOccurs_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - - var originHeadPath = Path.Combine(_testRepoPath, ".git", "refs", "remotes", "origin", "HEAD"); - File.WriteAllText(originHeadPath, "not a valid ref\n"); - - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.IsNull(result, "Should return null when exception occurs"); - } - } - - [TestMethod] - public void GetDefaultBranch_WhenBaselineConfigIsEmpty_UsesOriginHead() - { - WriteConfig("{\"baseline_branch\":\"\"}"); - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.AreEqual("main", result); - } - } - - [TestMethod] - public void GetDefaultBranch_WithBareRepository_ResolvesOriginHead() - { - var barePath = Path.Combine(Path.GetTempPath(), $"test-bare-main-{Guid.NewGuid()}"); - - try - { - ExecGit($"clone --bare \"{_testRepoPath}\" \"{barePath}\""); - - using (var repo = new Repository(barePath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Branches["main"].Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(barePath)) - { - Assert.IsTrue(string.IsNullOrEmpty(repo.Info.WorkingDirectory)); - - var result = MainBranchNames.GetDefaultBranch(repo); - - Assert.AreEqual("main", result); - } - } - finally - { - if (Directory.Exists(barePath)) - { - try - { - Directory.Delete(barePath, true); - } - catch - { - } - } - } - } - - [TestMethod] - public void GetDefaultBranch_CachesResult() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - string firstResult; - using (var repo = new Repository(_testRepoPath)) - { - firstResult = MainBranchNames.GetDefaultBranch(repo); - Assert.AreEqual("main", firstResult, "First call should return main"); - } - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Remove(repo.Refs["refs/remotes/origin/HEAD"]); - repo.Refs.Add("refs/remotes/origin/develop", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/develop"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var cachedResult = MainBranchNames.GetDefaultBranch(repo); - Assert.AreEqual("main", cachedResult, "Should return cached result even though underlying state changed"); - } - } - - [TestMethod] - public void GetDefaultBranch_DoesNotCacheNullResult() - { - using (var repo = new Repository(_testRepoPath)) - { - var firstResult = MainBranchNames.GetDefaultBranch(repo); - Assert.IsNull(firstResult, "First call should return null when no origin/HEAD"); - } - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var secondResult = MainBranchNames.GetDefaultBranch(repo); - Assert.AreEqual("main", secondResult, "Should return fresh result since null was not cached"); - } - } - - [TestMethod] - public void ClearDefaultBranchCache_InvalidatesCache() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - var firstResult = MainBranchNames.GetDefaultBranch(repo); - Assert.AreEqual("main", firstResult); - } - - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Remove(repo.Refs["refs/remotes/origin/HEAD"]); - repo.Refs.Add("refs/remotes/origin/develop", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/develop"]); - } - - MainBranchNames.ClearDefaultBranchCache(_testRepoPath); - - using (var repo = new Repository(_testRepoPath)) - { - var freshResult = MainBranchNames.GetDefaultBranch(repo); - Assert.AreEqual("develop", freshResult, "Should return fresh result after cache clear"); - } - } - - private void WriteConfig(string json) - { - var codesceneDir = Path.Combine(_testRepoPath, CodesceneFileWatcher.CodesceneDir); - Directory.CreateDirectory(codesceneDir); - File.WriteAllText(Path.Combine(codesceneDir, CodesceneFileWatcher.ConfigFileName), json); - } - - private void CommitFile(string filename, string content, string message) - { - var filePath = Path.Combine(_testRepoPath, filename); - File.WriteAllText(filePath, content); - - using (var repo = new Repository(_testRepoPath)) - { - Commands.Stage(repo, filename); - var signature = new Signature("Test User", "test@example.com", DateTimeOffset.Now); - repo.Commit(message, signature, signature); - } - } - - private void ExecGit(string args) - { - var psi = new ProcessStartInfo - { - FileName = "git", - Arguments = args, - WorkingDirectory = _testRepoPath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using (var process = Process.Start(psi)) - { - process.WaitForExit(); - if (process.ExitCode != 0) - { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"Git command failed: {args}\n{error}"); - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MergeBaseFinderTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MergeBaseFinderTests.cs deleted file mode 100644 index 7dacc7e2..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MergeBaseFinderTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class MergeBaseFinderTests : GitChangeDetectorTestBase - { - private MergeBaseFinder _finder; - - [TestInitialize] - public void SetupFinder() - { - _finder = new MergeBaseFinder(_fakeLogger); - } - - [TestMethod] - public void GetMergeBaseCommit_WithEmptyRepo_ReturnsNull() - { - var emptyRepoPath = Path.Combine(Path.GetTempPath(), $"empty-repo-{Guid.NewGuid()}"); - Directory.CreateDirectory(emptyRepoPath); - Repository.Init(emptyRepoPath); - - Commit result = null; - try - { - using (var repo = new Repository(emptyRepoPath)) - { - result = _finder.GetMergeBaseCommit(repo); - } - } - finally - { - try - { - Directory.Delete(emptyRepoPath, true); - } - catch - { - } - } - - Assert.IsNull(result, "Should return null for empty repository with no commits"); - } - - [TestMethod] - public void GetMergeBaseCommit_WithCorruptedRepo_HandlesGracefullyAndReturnsNull() - { - CorruptGitObjects(); - - using (var repo = new Repository(_testRepoPath)) - { - var result = _finder.GetMergeBaseCommit(repo); - - Assert.IsNull(result, "Should return null when git operations fail"); - } - } - - [TestMethod] - public void GetMergeBaseCommit_OnMainBranch_ReturnsNull() - { - using (var repo = new Repository(_testRepoPath)) - { - var result = _finder.GetMergeBaseCommit(repo); - - Assert.IsNull(result, "Should return null when on main branch"); - } - } - - [TestMethod] - public void GetMergeBaseCommit_OnFeatureBranchWithMergeBase_ReturnsCommit() - { - Commit expectedMergeBase; - using (var repo = new Repository(_testRepoPath)) - { - expectedMergeBase = repo.Head.Tip; - } - - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "feature content", "Add feature"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = _finder.GetMergeBaseCommit(repo); - - Assert.AreEqual(expectedMergeBase.Sha, result.Sha, "Should return the initial commit as merge base"); - Assert.IsTrue(_fakeLogger.SnapshotDebugMessages().Exists(m => m.Contains("Found merge base using branch")), "Should log merge base found"); - } - } - - [TestMethod] - public void GetMergeBaseCommit_WithDivergentBranches_ReturnsBranchPoint() - { - CommitFile("fileB.cs", "content B", "Commit B"); - - Commit expectedMergeBase; - using (var repo = new Repository(_testRepoPath)) - { - expectedMergeBase = repo.Head.Tip; - } - - string defaultBranch; - using (var repo = new Repository(_testRepoPath)) - { - defaultBranch = repo.Head.FriendlyName; - } - - ExecGit("checkout -b feature-branch"); - - CommitFile("featureE.cs", "feature E", "Commit E"); - CommitFile("featureF.cs", "feature F", "Commit F"); - - ExecGit($"checkout {defaultBranch}"); - CommitFile("fileC.cs", "content C", "Commit C"); - CommitFile("fileD.cs", "content D", "Commit D"); - - ExecGit("checkout feature-branch"); - - using (var repo = new Repository(_testRepoPath)) - { - var result = _finder.GetMergeBaseCommit(repo); - - Assert.IsNotNull(result, "Should find merge base for divergent branches"); - Assert.AreEqual( - expectedMergeBase.Sha, - result.Sha, - "Merge base should be commit B (branch point), not latest master"); - } - } - - [TestMethod] - public void IsMainBranch_WithMainBranches_ReturnsTrue() - { - Assert.IsTrue(_finder.IsMainBranch("main"), "Should recognize 'main'"); - Assert.IsTrue(_finder.IsMainBranch("master"), "Should recognize 'master'"); - Assert.IsTrue(_finder.IsMainBranch("develop"), "Should recognize 'develop'"); - Assert.IsTrue(_finder.IsMainBranch("trunk"), "Should recognize 'trunk'"); - Assert.IsTrue(_finder.IsMainBranch("dev"), "Should recognize 'dev'"); - } - - [TestMethod] - public void IsMainBranch_WithDifferentCasing_ReturnsTrue() - { - Assert.IsTrue(_finder.IsMainBranch("Main"), "Should be case-insensitive for 'Main'"); - Assert.IsTrue(_finder.IsMainBranch("MASTER"), "Should be case-insensitive for 'MASTER'"); - Assert.IsTrue(_finder.IsMainBranch("Develop"), "Should be case-insensitive for 'Develop'"); - } - - [TestMethod] - public void IsMainBranch_WithNonMainBranch_ReturnsFalse() - { - Assert.IsFalse(_finder.IsMainBranch("feature"), "Should not recognize 'feature'"); - Assert.IsFalse(_finder.IsMainBranch("bugfix"), "Should not recognize 'bugfix'"); - Assert.IsFalse(_finder.IsMainBranch("release"), "Should not recognize 'release'"); - } - - [TestMethod] - public void IsMainBranch_WithNullOrEmpty_ReturnsFalse() - { - Assert.IsFalse(_finder.IsMainBranch(null), "Should return false for null"); - Assert.IsFalse(_finder.IsMainBranch(string.Empty), "Should return false for empty string"); - } - - [TestMethod] - public void IsMainBranch_WithRepo_WhenOriginHeadIsMain_OnlyMainIsMainBranch() - { - using (var repo = new Repository(_testRepoPath)) - { - repo.Refs.Add("refs/remotes/origin/main", repo.Head.Tip.Id); - repo.Refs.Add("refs/remotes/origin/HEAD", repo.Refs["refs/remotes/origin/main"]); - } - - using (var repo = new Repository(_testRepoPath)) - { - Assert.IsTrue(_finder.IsMainBranch(repo, "main"), "main should be the main branch"); - Assert.IsFalse(_finder.IsMainBranch(repo, "master"), "master should not be main when origin/HEAD is main"); - } - } - - [TestMethod] - public void GetMergeBaseCommit_WithPartiallyCorruptedRepo_HandlesExceptionInFindMergeBase() - { - ExecGit("checkout -b feature-branch"); - CommitFile("feature.cs", "feature content", "Add feature"); - - CorruptGitObjects(); - - using (var repo = new Repository(_testRepoPath)) - { - var result = _finder.GetMergeBaseCommit(repo); - - Assert.IsNotEmpty(_fakeLogger.SnapshotDebugMessages(), "Should log debug messages"); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/NativeIdeServerProcessTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/NativeIdeServerProcessTests.cs new file mode 100644 index 00000000..a1d54f4b --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/NativeIdeServerProcessTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class NativeIdeServerProcessTests + { + [TestMethod] + public void Kill_WhenAlreadyExited_DoesNotThrow() + { + var factory = new NativeIdeServerProcessFactory(); + using var process = (NativeIdeServerProcess)factory.Start("cmd.exe", "/d /c exit"); + process.Kill(); + process.Kill(); + } + + [TestMethod] + public async Task BeginErrorReadLine_CapturesStderrSnapshot() + { + var factory = new NativeIdeServerProcessFactory(); + var received = new TaskCompletionSource(); + using var process = (NativeIdeServerProcess)factory.Start("cmd.exe", "/d /c \"echo server-error 1>&2\""); + process.ErrorDataReceived += (_, __) => received.TrySetResult(true); + process.BeginErrorReadLine(); + await received.Task; + Assert.Contains("server-error", process.StderrSnapshot); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PathUtilitiesTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PathUtilitiesTests.cs index 472928bd..47fd1833 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PathUtilitiesTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PathUtilitiesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) CodeScene. All rights reserved. using Codescene.VSExtension.Core.Application.Util; +using Codescene.VSExtension.Core.Util; namespace Codescene.VSExtension.Core.Tests { @@ -55,6 +56,14 @@ public void AppendDirectorySeparatorChar_PathWithoutSeparator_AppendsSeparator() Assert.AreEqual(pathWithoutSeparator + Path.DirectorySeparatorChar, result); } + [TestMethod] + public void NormalizeFsPath_InvalidPath_FallsBackToNormalizedInput() + { + var result = RpcPath.NormalizeFsPath("\0Invalid/Path/"); + + Assert.AreEqual("\0invalid\\path", result); + } + [TestMethod] [DataRow(@"C:\repo\.git\config", "Files in .git directory should return true")] [DataRow(@"C:\repo\.git\objects\pack\somefile", "Files deeply nested in .git directory should return true")] diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PerFileRequestQueueTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PerFileRequestQueueTests.cs deleted file mode 100644 index fae782bc..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PerFileRequestQueueTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Util; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class PerFileRequestQueueTests - { - [TestMethod] - public void TryStart_FirstRequest_ReturnsTrue() - { - var queue = new PerFileRequestQueue(); - - var started = queue.TryStart("file.cs", "first"); - - Assert.IsTrue(started); - } - - [TestMethod] - public void EnqueueLatest_ReplacesPendingRequest() - { - var queue = new PerFileRequestQueue(); - queue.TryStart("file.cs", "first"); - - queue.EnqueueLatest("file.cs", "second"); - queue.EnqueueLatest("file.cs", "third"); - - var hasNext = queue.CompleteAndGetNext("file.cs", out var nextRequest); - - Assert.IsTrue(hasNext); - Assert.AreEqual("third", nextRequest); - } - - [TestMethod] - public void CompleteAndGetNext_WithoutPendingRequest_ReturnsFalse() - { - var queue = new PerFileRequestQueue(); - queue.TryStart("file.cs", "first"); - - var hasNext = queue.CompleteAndGetNext("file.cs", out var nextRequest); - - Assert.IsFalse(hasNext); - Assert.IsNull(nextRequest); - Assert.IsTrue(queue.TryStart("file.cs", "second")); - } - - [TestMethod] - public void Clear_RemovesQueuedRequests() - { - var queue = new PerFileRequestQueue(); - queue.TryStart("file.cs", "first"); - queue.EnqueueLatest("file.cs", "second"); - - queue.Clear(); - - var hasNext = queue.CompleteAndGetNext("file.cs", out var nextRequest); - - Assert.IsFalse(hasNext); - Assert.IsNull(nextRequest); - Assert.IsTrue(queue.TryStart("file.cs", "third")); - } - - [TestMethod] - public void EnqueueLatest_NewFile_CreatesPendingRequest() - { - var queue = new PerFileRequestQueue(); - - queue.EnqueueLatest("file.cs", "pending"); - - var hasNext = queue.CompleteAndGetNext("file.cs", out var nextRequest); - - Assert.IsTrue(hasNext); - Assert.AreEqual("pending", nextRequest); - } - - [TestMethod] - public void TryStart_WithPendingRequest_StartsWorkerAndPreservesPendingRequest() - { - var queue = new PerFileRequestQueue(); - queue.EnqueueLatest("file.cs", "pending"); - - var started = queue.TryStart("file.cs", "ignored"); - - Assert.IsTrue(started); - Assert.IsTrue(queue.CompleteAndGetNext("file.cs", out var nextRequest)); - Assert.AreEqual("pending", nextRequest); - } - - [DataRow("")] - [DataRow(" ")] - [DataRow(null)] - [TestMethod] - public void TryStart_WithInvalidFileKey_ThrowsArgumentException(string fileKey) - { - var queue = new PerFileRequestQueue(); - - var exception = Assert.Throws(() => queue.TryStart(fileKey, "request")); - - Assert.AreEqual("fileKey", exception.ParamName); - } - - [DataRow("")] - [DataRow(" ")] - [DataRow(null)] - [TestMethod] - public void EnqueueLatest_WithInvalidFileKey_ThrowsArgumentException(string fileKey) - { - var queue = new PerFileRequestQueue(); - - var exception = Assert.Throws(() => queue.EnqueueLatest(fileKey, "request")); - - Assert.AreEqual("fileKey", exception.ParamName); - } - - [DataRow("")] - [DataRow(" ")] - [DataRow(null)] - [TestMethod] - public void CompleteAndGetNext_WithInvalidFileKey_ThrowsArgumentException(string fileKey) - { - var queue = new PerFileRequestQueue(); - - var exception = Assert.Throws(() => queue.CompleteAndGetNext(fileKey, out _)); - - Assert.AreEqual("fileKey", exception.ParamName); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs new file mode 100644 index 00000000..e5f04e71 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class PersistentCliSyncTests + { + [TestMethod] + public void LogFields_CapsLongListsAndSkipsNulls() + { + var fields = LogFields.Format( + LogFields.Field("path", "src/file.cs"), + LogFields.Field("missing", null), + LogFields.Field("files", new[] { "a", "b", "c", "d", "e" })); + + Assert.Contains("path=src/file.cs", fields); + Assert.DoesNotContain("missing", fields); + + var capped = LogFields.FormatCappedList(new[] { "1", "2", "3" }, 2); + Assert.AreEqual("1, 2 omitted=1", capped); + } + + [TestMethod] + public void DeltaScoreText_ShowsCurrentScoreWhenOldScoreIsMissing() + { + var text = DeltaScoreText.Format(new DeltaResponseModel { NewScore = 8.5m }); + + Assert.AreEqual(8.5m.ToString(), text); + } + + [TestMethod] + public void DeltaScoreText_ShowsBothScoresWhenOldScoreExists() + { + var text = DeltaScoreText.Format(new DeltaResponseModel { OldScore = 7m, NewScore = 8.5m }); + + Assert.AreEqual(7m + " → " + 8.5m, text); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ProcessExecutorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ProcessExecutorTests.cs deleted file mode 100644 index 2b4a91f1..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ProcessExecutorTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Security.Cryptography; -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class ProcessExecutorTests - { - private Mock _mockCliSettingsProvider; - private Mock _mockLogger; - private ProcessExecutor _processExecutor; - private string _tempFilePath; - - [TestInitialize] - public void Setup() - { - _mockCliSettingsProvider = new Mock(); - _mockLogger = new Mock(); - _tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".exe"); - } - - [TestCleanup] - public void Cleanup() - { - if (File.Exists(_tempFilePath)) - { - File.Delete(_tempFilePath); - } - } - - [TestMethod] - public void Execute_FileHashMismatch_ThrowsInvalidOperationException() - { - File.WriteAllText(_tempFilePath, "tampered cli"); - SetupCliPath(_tempFilePath, "0000000000000000000000000000000000000000000000000000000000000000"); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - - var exception = Assert.Throws(() => - _processExecutor.ExecuteAsync("version --sha").GetAwaiter().GetResult()); - - Assert.Contains("integrity verification", exception.Message); - } - - [TestMethod] - public void Execute_FileDoesNotExist_ThrowsFileNotFoundException() - { - // Arrange - _mockCliSettingsProvider.Setup(x => x.CliFileFullPath).Returns(_tempFilePath); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - - // Act & Assert - var exception = Assert.Throws(() => - _processExecutor.ExecuteAsync("version --sha").GetAwaiter().GetResult()); - - Assert.Contains("CodeScene CLI executable not found", exception.Message); - Assert.Contains("bundled with the extension", exception.Message); - Assert.AreEqual(_tempFilePath, exception.FileName); - } - - [TestMethod] - public void Execute_FileExists_FileNotFoundExceptionNotThrown() - { - // Arrange - File.WriteAllText(_tempFilePath, "dummy executable content"); - SetupCliPath(_tempFilePath); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - - // Act & Assert - // The file existence check should pass (file exists), so FileNotFoundException should not be thrown - // Note: Execution will likely fail with a different exception since this is not a real executable, - // but the important part is that FileNotFoundException is not thrown at the file existence check stage - try - { - _processExecutor.ExecuteAsync("version --sha").GetAwaiter().GetResult(); - - // If we get here without FileNotFoundException, the file existence check passed - } - catch (FileNotFoundException ex) - { - // This should not happen if the file exists - the exception should not mention file not found - Assert.Fail($"FileNotFoundException should not be thrown when file exists. Message: {ex.Message}"); - } - catch (Exception) - { - // Any other exception is expected (e.g., process execution failure, invalid executable format) - // The important thing is that FileNotFoundException was not thrown, meaning the file existence check passed - } - } - - [TestMethod] - public async Task Execute_WhenTimeoutExceeded_ThrowsTimeoutException() - { - var pingPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ping.exe"); - if (!File.Exists(pingPath)) - { - Assert.Inconclusive("ping.exe not found, skipping timeout test"); - return; - } - - SetupCliPath(pingPath); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - - var exception = await Assert.ThrowsAsync(() => - _processExecutor.ExecuteAsync("127.0.0.1 -n 100", null, TimeSpan.FromMilliseconds(1))); - - Assert.Contains("timeout", exception.Message); - Assert.Contains("1", exception.Message); - } - - [TestMethod] - public async Task Execute_WhenTimeoutExceededAndCancellationRequested_ThrowsOperationCanceledException() - { - var pingPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ping.exe"); - if (!File.Exists(pingPath)) - { - Assert.Inconclusive("ping.exe not found, skipping timeout test"); - return; - } - - SetupCliPath(pingPath); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Assert.ThrowsAsync(() => - _processExecutor.ExecuteAsync("127.0.0.1 -n 100", null, TimeSpan.FromMilliseconds(50), cts.Token)); - } - - [TestMethod] - public async Task Execute_WhenTimeoutExceededWithTelemetryArgument_ReturnsEmptyString() - { - var pingPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ping.exe"); - if (!File.Exists(pingPath)) - { - Assert.Inconclusive("ping.exe not found, skipping timeout test"); - return; - } - - SetupCliPath(pingPath); - _processExecutor = new ProcessExecutor(_mockCliSettingsProvider.Object, _mockLogger.Object); - - var result = await _processExecutor.ExecuteAsync("telemetry some-command", null, TimeSpan.FromMilliseconds(1)); - - Assert.AreEqual(string.Empty, result); - } - - private static string ComputeSha256Hex(string filePath) - { - using var stream = File.OpenRead(filePath); - using var sha = SHA256.Create(); - var hashBytes = sha.ComputeHash(stream); - return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant(); - } - - private void SetupCliPath(string cliFilePath, string requiredSha256Hex = null) - { - _mockCliSettingsProvider.Setup(x => x.CliFileFullPath).Returns(cliFilePath); - var sha256Hex = requiredSha256Hex ?? ComputeSha256Hex(cliFilePath); - _mockCliSettingsProvider.Setup(x => x.RequiredCliBinarySha256).Returns(sha256Hex); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineCanonicalRootTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineCanonicalRootTests.cs new file mode 100644 index 00000000..705a70cc --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineCanonicalRootTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Runtime.InteropServices; +using System.Text; +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ReviewPipelineCanonicalRootTests + { + private Mock _client; + private ReviewPipelinePresentationEvents _events; + private int _nextId; + private ReviewPipeline _pipeline; + + [TestInitialize] + public void Setup() + { + _client = new Mock(); + _events = new ReviewPipelinePresentationEvents(); + _nextId = 1; + _client.Setup(x => x.ReviewFiles(It.IsAny(), It.IsAny>())); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-" + _nextId++, null); + } + + [TestCleanup] + public void Cleanup() + { + _pipeline.Dispose(); + } + + [TestMethod] + public async Task Submit_AcceptsCanonicalRepoRootFromNotification() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive(); + return; + } + + var localRoot = Path.Combine(Path.GetTempPath(), "pipeline-canonical-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(localRoot); + var shortRoot = GetShortPath(localRoot); + if (RpcPath.PathsEqual(localRoot, shortRoot)) + { + Directory.Delete(localRoot, true); + Assert.Inconclusive("Short path alias unavailable for temp directory."); + return; + } + + var srcDir = Path.Combine(localRoot, "src"); + Directory.CreateDirectory(srcDir); + var document = new ReviewDocument + { + FilePath = Path.Combine(srcDir, "file.ts"), + Content = "const value = 1;", + IsDirty = true, + }; + var reviewPromise = _pipeline.SubmitAsync( + localRoot, + new ReviewSubmission + { + Document = document, + RelPath = "src/file.ts", + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + Id = "review-1", + RepoRoot = shortRoot, + Path = "src/file.ts", + Result = new CliReviewModel + { + FileLevelCodeSmells = new List(), + FunctionLevelCodeSmells = new List(), + RawScore = "raw", + }, + }); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification { Id = "review-1", RepoRoot = shortRoot, Path = "src/file.ts", Result = null }); + + try + { + var result = await reviewPromise; + Assert.IsNotNull(result.Review); + Assert.HasCount(1, _events.Reviews); + Assert.HasCount(1, _events.Deltas); + } + finally + { + Directory.Delete(localRoot, true); + } + } + + private static string GetShortPath(string longPath) + { + var buffer = new StringBuilder(512); + var length = GetShortPathName(longPath, buffer, (uint)buffer.Capacity); + if (length == 0 || length >= buffer.Capacity) + { + return longPath; + } + + return buffer.ToString(); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern uint GetShortPathName(string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs new file mode 100644 index 00000000..dba97b6d --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -0,0 +1,562 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.IO; +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ReviewPipelineNotificationCoverageTests + { + private const string RepoRoot = "/repo"; + private Mock _client; + private ReviewPipelinePresentationEvents _events; + private List<(string RepoRoot, IReadOnlyList Files)> _batches; + private ReviewPipeline _pipeline; + + [TestInitialize] + public void Setup() + { + _client = new Mock(); + _events = new ReviewPipelinePresentationEvents(); + _batches = new List<(string, IReadOnlyList)>(); + _client.Setup(x => x.ReviewFiles(It.IsAny(), It.IsAny>())) + .Callback>((root, files) => _batches.Add((root, files.ToList()))); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", null); + } + + [TestCleanup] + public void Cleanup() + { + _pipeline.Dispose(); + } + + [TestMethod] + public async Task WatchReview_WhenAutomaticAnalysisDisabled_IsIgnored() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var settings = new Mock(); + settings.Setup(x => x.EnableAutomaticAnalysis).Returns(false); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, true), () => "review-1", null, settings.Object); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + + await Task.Delay(50); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingDelta_WhenAutomaticAnalysisDisabled_PresentsWithoutMonitorUpdate() + { + var settings = new Mock(); + settings.Setup(x => x.EnableAutomaticAnalysis).Returns(false); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", null, settings.Object); + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + RaiseReview("review-1", RepoRoot, "src/file.ts"); + RaiseDelta("review-1", RepoRoot, "src/file.ts", new DeltaResponseModel { NewGitBlobSha = GitBlobSha.FromUtf8(document.Content) }); + + await pending; + Assert.HasCount(1, _events.Deltas); + Assert.IsFalse(_events.Deltas[0].UpdateMonitor); + } + + [TestMethod] + public async Task WatchReviewFailed_WithoutId_LogsWarning() + { + var logger = new Mock(); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", logger.Object); + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Message = "watch failed" }); + + logger.Verify( + x => x.Warn(It.Is(message => message.Contains("watch reviewFailed"))), + Times.Once); + } + + [TestMethod] + public async Task WatchDelta_WhenFilterRejects_IsIgnored() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, true), () => "review-1", null); + _pipeline.SetWatchDeltaFilter((_, __) => Task.FromResult(false)); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = new DeltaResponseModel { NewGitBlobSha = GitBlobSha.FromUtf8(document.Content) }, + }); + + await Task.Delay(50); + Assert.IsEmpty(_events.Deltas); + } + + [TestMethod] + public async Task WatchDelta_WhenEpochInvalidatedDuringPresentation_IsDropped() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var slowAccess = new Mock(); + slowAccess.Setup(x => x.FindOpenDocument(It.IsAny())).Returns((ReviewDocument)null); + slowAccess.Setup(x => x.OpenDocumentAsync(It.IsAny())) + .Returns(async () => + { + await Task.Delay(200); + return document; + }); + slowAccess.Setup(x => x.ReadFileBytesAsync(It.IsAny())) + .ReturnsAsync(System.Text.Encoding.UTF8.GetBytes(document.Content)); + slowAccess.Setup(x => x.IsVisible(It.IsAny())).Returns(true); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, slowAccess.Object, () => "review-1", null); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = new DeltaResponseModel { NewGitBlobSha = GitBlobSha.FromUtf8(document.Content) }, + }); + _pipeline.Invalidate(); + await Task.Delay(300); + Assert.IsEmpty(_events.Deltas); + } + + [TestMethod] + public async Task WatchReview_WhenEpochInvalidatedDuringPresentation_IsDropped() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var slowAccess = new Mock(); + slowAccess.Setup(x => x.FindOpenDocument(It.IsAny())).Returns((ReviewDocument)null); + slowAccess.Setup(x => x.OpenDocumentAsync(It.IsAny())) + .Returns(async () => + { + await Task.Delay(200); + return document; + }); + slowAccess.Setup(x => x.ReadFileBytesAsync(It.IsAny())) + .ReturnsAsync(System.Text.Encoding.UTF8.GetBytes(document.Content)); + slowAccess.Setup(x => x.IsVisible(It.IsAny())).Returns(true); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, slowAccess.Object, () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + _pipeline.Invalidate(); + await Task.Delay(300); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingReviewFailed_WithRepoMismatch_IgnoresPending() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification + { + Id = "review-1", + RepoRoot = "/other", + Path = "src/file.ts", + Message = "failed", + }); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsNull(result.Delta); + } + + [TestMethod] + public async Task PendingReviewFailed_WithMatchingIdentity_PropagatesFailure() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification + { + Id = "review-1", + RepoRoot = RepoRoot, + Path = "src/file.ts", + Message = "failed", + }); + + await Assert.ThrowsAsync(() => pending); + } + + [TestMethod] + public void Remove_WithNullDocuments_DoesNotThrow() + { + _pipeline.Remove(RepoRoot, null); + } + + [TestMethod] + public async Task Submit_ReusesPendingSubmissionForIdenticalContent() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var first = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + var second = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + Assert.HasCount(1, _batches); + RaiseReview("review-1", RepoRoot, "src/file.ts"); + RaiseDelta("review-1", RepoRoot, "src/file.ts", new DeltaResponseModel()); + await first; + await second; + } + + [TestMethod] + public void Invalidate_LogsEpochChange() + { + var logger = new Mock(); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", logger.Object); + _pipeline.Invalidate(); + logger.Verify(x => x.Debug(It.Is(message => message.Contains("epochInvalidated"))), Times.Once); + } + + [TestMethod] + public void ReleaseBufferMonitorOwnership_AllowsResubmitWithMonitor() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.ReleaseBufferMonitorOwnership(RepoRoot, document); + _ = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + Assert.HasCount(1, _batches); + } + + [TestMethod] + public async Task Submit_NullDocument_Throws() + { + await Assert.ThrowsAsync(() => _pipeline.SubmitAsync( + RepoRoot, + new ReviewSubmission { Document = null, RelPath = "src/file.ts", Content = "x" })); + } + + [TestMethod] + public void RestoreFromDiskIfBufferOwned_NullDocument_NoOp() + { + _pipeline.RestoreFromDiskIfBufferOwned(RepoRoot, null); + } + + [TestMethod] + public async Task Reset_CompletesPendingReviews() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _pipeline.Reset(); + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsNull(result.Delta); + } + + [TestMethod] + public async Task PendingDelta_WithRepoMismatch_IgnoresDelta() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + RaiseReview("review-1", RepoRoot, "src/file.ts"); + RaiseDelta("review-1", "/other", "src/file.ts", new DeltaResponseModel { NewGitBlobSha = GitBlobSha.FromUtf8(document.Content) }); + + var result = await pending; + Assert.IsNotNull(result.Review); + Assert.IsNull(result.Delta); + } + + [TestMethod] + public async Task WatchReview_EvictsOldestCachedEntries() + { + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < 252; i++) + { + var relPath = $"src/file{i}.ts"; + var path = RpcPath.CombineRepo(RpcPath.ToLocalRepoRoot(RepoRoot), relPath); + documents[path] = Document(path, $"content-{i}"); + } + + var access = new Mock(); + access.Setup(x => x.FindOpenDocument(It.IsAny())) + .Returns((string path) => documents.ContainsKey(path) ? documents[path] : null!); + access.Setup(x => x.OpenDocumentAsync(It.IsAny())) + .Returns((string path) => Task.FromResult(documents.ContainsKey(path) ? documents[path] : null!)); + access.Setup(x => x.ReadFileBytesAsync(It.IsAny())) + .Returns(path => Task.FromResult( + documents.TryGetValue(path, out var document) + ? System.Text.Encoding.UTF8.GetBytes(document.Content) + : null)); + access.Setup(x => x.IsVisible(It.IsAny())).Returns(true); + + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, access.Object, () => "review-1", null); + + for (var i = 0; i < 252; i++) + { + var relPath = $"src/file{i}.ts"; + var content = $"content-{i}"; + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = relPath, + Result = EmptyReview(GitBlobSha.FromUtf8(content)), + }); + } + + await WaitUntil(() => _events.Reviews.Count >= 250, attempts: 500); + Assert.IsGreaterThanOrEqualTo(250, _events.Reviews.Count); + } + + [TestMethod] + public async Task PendingReview_WithRepoMismatch_IgnoresReview() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + RaiseReview("review-1", "/other", "src/file.ts"); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingReview_WithPathMismatch_IgnoresReview() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + RaiseReview("review-1", RepoRoot, "src/other.ts"); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingReview_WhenSuperseded_IgnoresStaleNotification() + { + var idSource = 0; + var document = Document("/repo/src/file.ts", "first"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline( + _client.Object, + _events, + FileAccess(document, true), + () => $"review-{++idSource}", + null); + + var pendingFirst = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + var updated = Document("/repo/src/file.ts", "second"); + _ = _pipeline.SubmitAsync( + RepoRoot, + new ReviewSubmission + { + Document = updated, + RelPath = "src/file.ts", + Content = updated.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + Id = "review-1", + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + + var result = await pendingFirst; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingReview_AfterDocumentMutation_IgnoresReview() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + document.Content = "const value = 2;"; + RaiseReview("review-1", RepoRoot, "src/file.ts"); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task PendingReview_AfterRemove_IgnoresReview() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _pipeline.Remove(RepoRoot, document); + RaiseReview("review-1", RepoRoot, "src/file.ts"); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task WatchReview_WithDiskFileAccess_PresentsFromDisk() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "pipeline-disk-" + Guid.NewGuid().ToString("N")); + var filePath = Path.Combine(repoRoot, "src", "file.ts"); + var parentDir = Path.GetDirectoryName(filePath) ?? throw new InvalidOperationException(); + Directory.CreateDirectory(parentDir); + const string content = "const disk = 1;"; + File.WriteAllText(filePath, content); + + try + { + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = repoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(content)), + }); + + await WaitUntil(() => _events.Reviews.Count >= 1); + Assert.AreEqual(content, _events.Reviews[0].Content); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, true); + } + } + } + + [TestMethod] + public async Task PendingReview_IgnoresMismatchedGitBlobSha() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + Id = "review-1", + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview("stale-sha"), + }); + + var result = await pending; + Assert.IsNull(result.Review); + Assert.IsEmpty(_events.Reviews); + } + + private void RaiseReview(string id, string repoRoot, string path) + { + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { Id = id, RepoRoot = repoRoot, Path = path, Result = EmptyReview() }); + } + + private void RaiseDelta(string id, string repoRoot, string path, DeltaResponseModel result) + { + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification { Id = id, RepoRoot = repoRoot, Path = path, Result = result }); + } + + private ReviewDocument Document(string filePath, string content) + { + return new ReviewDocument { FilePath = filePath, Content = content, IsDirty = true }; + } + + private ReviewSubmission Submission(ReviewDocument document) + { + return new ReviewSubmission + { + Document = document, + RelPath = "src/file.ts", + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }; + } + + private CliReviewModel EmptyReview(string gitBlobSha = null) + { + return new CliReviewModel + { + FileLevelCodeSmells = new List(), + FunctionLevelCodeSmells = new List(), + RawScore = "raw", + GitBlobSha = gitBlobSha, + }; + } + + private IReviewPipelineFileAccess FileAccess(ReviewDocument document, bool visible) + { + var mock = new Mock(); + mock.Setup(x => x.FindOpenDocument(It.IsAny())).Returns(document); + mock.Setup(x => x.OpenDocumentAsync(It.IsAny())).ReturnsAsync(document); + mock.Setup(x => x.ReadFileBytesAsync(It.IsAny())) + .ReturnsAsync(System.Text.Encoding.UTF8.GetBytes(document.Content ?? string.Empty)); + mock.Setup(x => x.IsVisible(It.IsAny())).Returns(visible); + return mock.Object; + } + + private async Task WaitUntil(Func predicate, int attempts = 100) + { + for (var attempt = 0; attempt < attempts; attempt++) + { + if (predicate()) + { + return; + } + + await Task.Delay(20); + } + + throw new TimeoutException("Timed out waiting for pipeline presentation."); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs new file mode 100644 index 00000000..b170c89c --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ReviewPipelineOwnershipTests + { + private Mock _client; + private PresentationEvents _events; + private List<(string RepoRoot, IReadOnlyList Files)> _batches; + private ReviewPipeline _pipeline; + private string _repo; + + [TestInitialize] + public void Setup() + { + _repo = Path.Combine(Path.GetTempPath(), "pipeline-own-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(_repo, "src")); + _client = new Mock(); + _events = new PresentationEvents(); + _batches = new List<(string, IReadOnlyList)>(); + _client.Setup(x => x.ReviewFiles(It.IsAny(), It.IsAny>())) + .Callback>((root, files) => _batches.Add((root, files.ToList()))); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-1", null); + } + + [TestCleanup] + public void Cleanup() + { + _pipeline.Dispose(); + if (Directory.Exists(_repo)) + { + Directory.Delete(_repo, true); + } + } + + [TestMethod] + public void RestoreFromDiskIfBufferOwned_SubmitsDiskReviewAfterRemove() + { + var document = Sample(); + _ = _pipeline.SubmitAsync(_repo, Submission(document)); + + _pipeline.RestoreFromDiskIfBufferOwned(_repo, document); + + Assert.HasCount(2, _batches); + Assert.IsNull(_batches[1].Files[0].Content); + Assert.HasCount(1, _events.Removed); + } + + [TestMethod] + public void SuspendBufferReviews_SkipsDiskRestoreOfOwnedBuffer() + { + var document = Sample(); + _ = _pipeline.SubmitAsync(_repo, Submission(document)); + _pipeline.SuspendBufferReviews(); + + _pipeline.RestoreFromDiskIfBufferOwned(_repo, document); + + Assert.HasCount(1, _batches); + Assert.HasCount(0, _events.Removed); + } + + [TestMethod] + public void ReleaseBufferMonitorOwnership_SkipsDiskRestore() + { + var document = Sample(); + _ = _pipeline.SubmitAsync(_repo, Submission(document)); + + _pipeline.ReleaseBufferMonitorOwnership(_repo, document); + _pipeline.RestoreFromDiskIfBufferOwned(_repo, document); + + Assert.HasCount(1, _batches); + Assert.HasCount(0, _events.Removed); + } + + [TestMethod] + public async Task WatchReview_DroppedForStaleBuffer_RestoresFromDiskOnceBufferMatches() + { + var document = Sample(); + UsePipelineWithOpenDocument(document, System.Text.Encoding.UTF8.GetBytes("const value = 2;")); + + RaiseWatchReview(GitBlobSha.FromUtf8("const value = 2;")); + await Task.Delay(50); + _pipeline.RestoreFromDiskIfBufferOwned(_repo, document); + + Assert.HasCount(0, _events.Reviews); + Assert.HasCount(1, _batches); + Assert.IsNull(_batches[0].Files[0].Content); + } + + [TestMethod] + public async Task WatchReview_DroppedForStaleDisk_DoesNotClaimBufferOwnership() + { + var document = Sample(); + UsePipelineWithOpenDocument(document, System.Text.Encoding.UTF8.GetBytes(document.Content)); + + RaiseWatchReview(GitBlobSha.FromUtf8("const value = 2;")); + await Task.Delay(50); + _pipeline.RestoreFromDiskIfBufferOwned(_repo, document); + + Assert.HasCount(0, _events.Reviews); + Assert.HasCount(0, _batches); + } + + [TestMethod] + public async Task WatchDelta_OutsideInventory_IsIgnored() + { + _pipeline.SetWatchDeltaFilter((_, __) => Task.FromResult(false)); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification { RepoRoot = _repo, Path = "src/file.ts", Result = new DeltaResponseModel() }); + + await Task.Delay(50); + Assert.HasCount(0, _events.Deltas); + } + + private void UsePipelineWithOpenDocument(ReviewDocument document, byte[] diskBytes) + { + var fileAccess = new Mock(); + fileAccess.Setup(x => x.FindOpenDocument(It.IsAny())).Returns(document); + fileAccess.Setup(x => x.ReadFileBytesAsync(It.IsAny())).ReturnsAsync(diskBytes); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, fileAccess.Object, () => "review-1", null); + } + + private void RaiseWatchReview(string gitBlobSha) + { + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = _repo, + Path = "src/file.ts", + Result = new CliReviewModel + { + FileLevelCodeSmells = new List(), + FunctionLevelCodeSmells = new List(), + GitBlobSha = gitBlobSha, + }, + }); + } + + private ReviewDocument Sample() + { + return Document(Path.Combine(_repo, "src", "file.ts"), "const value = 1;"); + } + + private ReviewDocument Document(string filePath, string content) + { + return new ReviewDocument { FilePath = filePath, Content = content, IsDirty = true }; + } + + private ReviewSubmission Submission(ReviewDocument document) + { + return new ReviewSubmission + { + Document = document, + RelPath = RpcPath.RelativePosix(_repo, document.FilePath), + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }; + } + + private sealed class PresentationEvents : IReviewPipelinePresentation + { + public List Reviews { get; } = new List(); + + public List Deltas { get; } = new List(); + + public List Removed { get; } = new List(); + + public void ReviewStarted(ReviewDocument document) + { + } + + public void ReviewFinished(ReviewDocument document) + { + } + + public void DeltaStarted(ReviewDocument document) + { + } + + public void DeltaFinished(ReviewDocument document) + { + } + + public void PresentReview(PresentedReview review) + { + Reviews.Add(review); + } + + public void PresentDelta(PresentedDelta delta) + { + Deltas.Add(delta); + } + + public void Remove(ReviewDocument document) + { + Removed.Add(document); + } + + public void Failed(Exception error) + { + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelinePresentationEvents.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelinePresentationEvents.cs new file mode 100644 index 00000000..bbecca97 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelinePresentationEvents.cs @@ -0,0 +1,55 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Tests +{ + internal sealed class ReviewPipelinePresentationEvents : IReviewPipelinePresentation + { + public List Reviews { get; } = new List(); + + public List Deltas { get; } = new List(); + + public List Removed { get; } = new List(); + + public List Failures { get; } = new List(); + + public void ReviewStarted(ReviewDocument document) + { + } + + public void ReviewFinished(ReviewDocument document) + { + } + + public void DeltaStarted(ReviewDocument document) + { + } + + public void DeltaFinished(ReviewDocument document) + { + } + + public void PresentReview(PresentedReview review) + { + Reviews.Add(review); + } + + public void PresentDelta(PresentedDelta delta) + { + Deltas.Add(delta); + } + + public void Remove(ReviewDocument document) + { + Removed.Add(document); + } + + public void Failed(Exception error) + { + Failures.Add(error); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs new file mode 100644 index 00000000..490d8469 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -0,0 +1,683 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ReviewPipelineTests + { + private const string RepoRoot = "/repo"; + private Mock _client; + private ReviewPipelinePresentationEvents _events; + private List<(string RepoRoot, IReadOnlyList Files)> _batches; + private int _nextId; + private ReviewPipeline _pipeline; + + [TestInitialize] + public void Setup() + { + _client = new Mock(); + _events = new ReviewPipelinePresentationEvents(); + _batches = new List<(string, IReadOnlyList)>(); + _nextId = 1; + _client.Setup(x => x.ReviewFiles(It.IsAny(), It.IsAny>())) + .Callback>((root, files) => _batches.Add((root, files.ToList()))); + _pipeline = new ReviewPipeline(_client.Object, _events, null, () => "review-" + _nextId++, null); + } + + [TestCleanup] + public void Cleanup() + { + _pipeline.Dispose(); + } + + [TestMethod] + public async Task SuspendBufferReviews_SkipsContentUntilResumed() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.SuspendBufferReviews(); + _pipeline.Reset(); + + var skipped = await _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + Assert.IsNull(skipped.Review); + Assert.IsNull(skipped.Delta); + Assert.HasCount(0, _batches); + + _pipeline.ResumeBufferReviews(); + var pending = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + Assert.HasCount(1, _batches); + Assert.AreEqual("const value = 1;", _batches[0].Files[0].Content); + CompleteReview("review-1"); + await pending; + } + + [TestMethod] + public void SubmitBatch_DeduplicatesRepoPathAndContent() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _ = _pipeline.SubmitBatchAsync(RepoRoot, new[] { Submission(document), Submission(document) }); + + Assert.HasCount(1, _batches); + Assert.HasCount(1, _batches[0].Files); + Assert.AreEqual("src/file.ts", _batches[0].Files[0].RelPath); + } + + [TestMethod] + public async Task Submit_DoesNotResubmitCompletedUnchangedContent() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var first = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + CompleteReview("review-1"); + await first; + + await _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + Assert.HasCount(1, _batches); + } + + [TestMethod] + public async Task Submit_OnlyPresentsLatestGeneration() + { + var oldDocument = Document("/repo/src/file.ts", "const value = 1;"); + var newDocument = Document("/repo/src/file.ts", "const value = 2;"); + var oldPromise = _pipeline.SubmitAsync(RepoRoot, Submission(oldDocument)); + var newPromise = _pipeline.SubmitAsync(RepoRoot, Submission(newDocument)); + + RaiseReview("review-1", RepoRoot, "src/file.ts"); + RaiseDelta("review-1", RepoRoot, "src/file.ts", null); + RaiseReview("review-2", RepoRoot, "src/file.ts"); + RaiseDelta("review-2", RepoRoot, "src/file.ts", null); + + await Task.WhenAll(oldPromise, newPromise); + Assert.HasCount(1, _events.Reviews); + Assert.AreEqual("const value = 2;", _events.Reviews[0].Document.Content); + Assert.HasCount(1, _events.Deltas); + Assert.IsNull(_events.Deltas[0].Result); + } + + [TestMethod] + public async Task Remove_TombstonesPathAndIgnoresLateResponses() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var reviewPromise = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + _pipeline.Remove(RepoRoot, document); + + RaiseReview("review-1", RepoRoot, "src/file.ts"); + RaiseDelta("review-1", RepoRoot, "src/file.ts", null); + + await reviewPromise; + Assert.HasCount(1, _events.Removed); + Assert.HasCount(0, _events.Reviews); + Assert.HasCount(0, _events.Deltas); + } + + [TestMethod] + public async Task Submit_IgnoresNotificationsFromAnotherRepository() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var reviewPromise = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + RaiseReview("review-1", "/other", "src/file.ts"); + RaiseDelta("review-1", "/other", "src/file.ts", null); + + await reviewPromise; + Assert.HasCount(0, _events.Reviews); + Assert.HasCount(0, _events.Deltas); + } + + [TestMethod] + public async Task Submit_KeepsIdenticalRelativePathsIsolatedByRepository() + { + var firstDocument = Document("/repo/src/file.ts", "const value = 1;"); + var secondDocument = Document("/other/src/file.ts", "const value = 2;"); + var firstPromise = _pipeline.SubmitAsync("/repo", Submission(firstDocument)); + var secondPromise = _pipeline.SubmitAsync("/other", Submission(secondDocument)); + + RaiseReview("review-1", "/repo", "src/file.ts"); + RaiseDelta("review-1", "/repo", "src/file.ts", null); + RaiseReview("review-2", "/other", "src/file.ts"); + RaiseDelta("review-2", "/other", "src/file.ts", null); + + await Task.WhenAll(firstPromise, secondPromise); + CollectionAssert.AreEqual( + new[] { firstDocument.FilePath, secondDocument.FilePath }, + _events.Reviews.Select(review => review.Document.FilePath).ToArray()); + } + + [TestMethod] + public async Task Submit_ReportsReviewFailures() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var reviewPromise = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification { Id = "review-1", RepoRoot = RepoRoot, Path = "src/file.ts", Message = "Review failed" }); + + var exception = await Assert.ThrowsAsync(() => reviewPromise); + Assert.AreEqual("Review failed", exception.Message); + Assert.HasCount(1, _events.Failures); + } + + [TestMethod] + public async Task Submit_AcceptsWindowsPathSeparatorAndDriveCaseDifferences() + { + const string windowsRoot = @"c:\Git\codescene"; + var document = Document(@"c:\Git\codescene\CSharp\Example.cs", "class Example {}"); + var reviewPromise = _pipeline.SubmitAsync( + windowsRoot, + new ReviewSubmission + { + Document = document, + RelPath = @"CSharp\Example.cs", + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }); + + CompleteReview("review-1", @"C:\Git\codescene", "CSharp/Example.cs"); + + var result = await reviewPromise; + Assert.IsNotNull(result.Review); + Assert.HasCount(1, _events.Reviews); + Assert.HasCount(1, _events.Deltas); + } + + [TestMethod] + public void SubmitBatch_SubmitsDiskFilesWithoutIdOrContent() + { + var submissions = new[] + { + new ReviewSubmission + { + RelPath = "src/file.ts", + UpdateDiagnosticsPane = false, + UpdateMonitor = true, + }, + }; + _ = _pipeline.SubmitBatchAsync(RepoRoot, submissions); + + Assert.HasCount(1, _batches); + Assert.IsNull(_batches[0].Files[0].Id); + Assert.IsNull(_batches[0].Files[0].Content); + Assert.AreEqual("src/file.ts", _batches[0].Files[0].RelPath); + } + + [TestMethod] + public async Task WatchReview_PresentsWhenGitBlobShaMatches() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, true), () => "review-1", null); + var sha = GitBlobSha.FromUtf8(document.Content); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Result = EmptyReview(sha) }); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = new DeltaResponseModel { NewGitBlobSha = sha }, + }); + + await WaitUntil(() => _events.Reviews.Count == 1 && _events.Deltas.Count == 1); + Assert.IsTrue(_events.Reviews[0].UpdateDiagnosticsPane); + Assert.IsTrue(_events.Reviews[0].UpdateMonitor); + Assert.AreEqual(sha, _events.Deltas[0].Result.NewGitBlobSha); + } + + [TestMethod] + public async Task WatchReview_DiscardsStaleGitBlobSha() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Result = EmptyReview("stale-sha") }); + + await Task.Delay(50); + Assert.HasCount(0, _events.Reviews); + } + + [TestMethod] + public async Task WatchReview_OpenUnmodifiedDocumentWithBom_IsPresented() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + var diskBytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(System.Text.Encoding.UTF8.GetBytes(document.Content)).ToArray(); + var fileAccess = new Mock(); + fileAccess.Setup(x => x.FindOpenDocument(It.IsAny())).Returns(document); + fileAccess.Setup(x => x.ReadFileBytesAsync(It.IsAny())).ReturnsAsync(diskBytes); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, fileAccess.Object, () => "review-1", null); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Result = EmptyReview(GitBlobSha.FromBytes(diskBytes)) }); + + await WaitUntil(() => _events.Reviews.Count == 1); + Assert.IsTrue(_events.Reviews[0].UpdateMonitor); + } + + [TestMethod] + public async Task WatchReview_ServesDiagnosticsOnlySubmissionWithoutResubmitting() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + await WaitUntil(() => _events.Reviews.Count == 1); + + var submission = Submission(document); + submission.UpdateMonitor = false; + var review = await _pipeline.SubmitAsync(RepoRoot, submission); + + Assert.HasCount(0, _batches); + Assert.HasCount(2, _events.Reviews); + Assert.IsNotNull(review.Review); + } + + [TestMethod] + public async Task WatchReview_StillSubmitsWhenMonitorNeedsDelta() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + await WaitUntil(() => _events.Reviews.Count == 1); + + _ = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + + Assert.HasCount(1, _batches); + } + + [TestMethod] + public async Task WatchReview_StillSubmitsWhenBufferContentDiffers() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + await WaitUntil(() => _events.Reviews.Count == 1); + + var edited = Document("/repo/src/file.ts", "const value = 2;"); + var submission = Submission(edited); + submission.UpdateMonitor = false; + _ = _pipeline.SubmitAsync(RepoRoot, submission); + + Assert.HasCount(1, _batches); + } + + [TestMethod] + public async Task WatchReview_StillSubmitsAfterInvalidate() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }); + await WaitUntil(() => _events.Reviews.Count == 1); + + _pipeline.Invalidate(); + var submission = Submission(document); + submission.UpdateMonitor = false; + _ = _pipeline.SubmitAsync(RepoRoot, submission); + + Assert.HasCount(1, _batches); + } + + [TestMethod] + public async Task Reset_DropsLaterWatchDelta() + { + var document = Document("/repo/src/file.ts", "const value = 1;"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, false), () => "review-1", null); + _pipeline.SetActiveRepos(Array.Empty()); + _pipeline.Reset(); + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = new DeltaResponseModel { OldScore = 10, NewScore = 9 }, + }); + await Task.Delay(50); + + Assert.IsEmpty(_events.Deltas); + } + + [TestMethod] + public async Task InvalidAndUnmatchedNotifications_AreIgnored() + { + _client.Raise(x => x.ReviewReceived += null, _client.Object, null); + _client.Raise(x => x.DeltaReceived += null, _client.Object, null); + _client.Raise(x => x.ReviewFailed += null, _client.Object, null); + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Message = "watch failed" }); + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification { Id = "unknown", RepoRoot = RepoRoot, Path = "src/file.ts", Message = "failed" }); + _client.Raise(x => x.ServerError += null, _client.Object, null); + + await Task.Delay(50); + + Assert.IsEmpty(_events.Reviews); + Assert.IsEmpty(_events.Deltas); + Assert.IsEmpty(_events.Failures); + } + + [TestMethod] + public async Task Submit_IgnoresReviewFailureFromAnotherRepository() + { + var reviewPromise = _pipeline.SubmitAsync( + RepoRoot, + Submission(Document("/repo/src/file.ts", "content"))); + + _client.Raise( + x => x.ReviewFailed += null, + _client.Object, + new ReviewFailedNotification + { + Id = "review-1", + RepoRoot = "/other", + Path = "src/file.ts", + Message = "failed", + }); + + var result = await reviewPromise; + + Assert.IsNull(result.Review); + Assert.IsNull(result.Delta); + Assert.IsEmpty(_events.Failures); + } + + [TestMethod] + public async Task WatchReview_OutsideActiveRepository_IsIgnored() + { + _pipeline.SetActiveRepos(new[] { "/other" }); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { RepoRoot = RepoRoot, Path = "src/file.ts", Result = EmptyReview() }); + await Task.Delay(50); + + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task WatchReview_DefaultFileAccessLoadsFileFromDisk() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "pipeline-disk-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + var filePath = Path.Combine(repoRoot, "file.cs"); + const string content = "class Example {}"; + File.WriteAllText(filePath, content); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events); + + try + { + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = repoRoot, + Path = "file.cs", + Result = EmptyReview(GitBlobSha.FromUtf8(content)), + }); + + await WaitUntil(() => _events.Reviews.Count == 1); + Assert.AreEqual(content, _events.Reviews[0].Content); + Assert.IsFalse(_events.Reviews[0].Document.IsDirty); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + + [TestMethod] + public async Task DefaultPresentation_HandlesReviewLifecycleAndServerFailure() + { + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object); + var document = Document("/repo/src/file.ts", "content"); + var completed = _pipeline.SubmitAsync(RepoRoot, Submission(document)); + var id = _batches[0].Files[0].Id; + RaiseReview(id, RepoRoot, "src/file.ts"); + RaiseDelta(id, RepoRoot, "src/file.ts", new DeltaResponseModel()); + await completed; + _pipeline.Remove(RepoRoot, document); + + var pending = _pipeline.SubmitAsync( + RepoRoot, + Submission(Document("/repo/src/file.ts", "changed"))); + _client.Raise(x => x.ServerError += null, _client.Object, new Exception("server failed")); + + await Assert.ThrowsAsync(() => pending); + } + + [TestMethod] + public async Task Submit_IgnoresDeltaFromAnotherRepository() + { + var pending = _pipeline.SubmitAsync( + RepoRoot, + Submission(Document("/repo/src/file.ts", "content"))); + + RaiseDelta("review-1", "/other", "src/file.ts", new DeltaResponseModel()); + RaiseReview("review-1", RepoRoot, "src/file.ts"); + + var result = await pending; + Assert.IsNotNull(result.Review); + Assert.IsNull(result.Delta); + Assert.IsEmpty(_events.Deltas); + } + + [TestMethod] + public async Task WatchDelta_MissingDocument_IsIgnored() + { + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification + { + RepoRoot = RepoRoot, + Path = "missing.cs", + Result = new DeltaResponseModel(), + }); + + await Task.Delay(50); + Assert.IsEmpty(_events.Deltas); + } + + [TestMethod] + public async Task WatchReview_OpenDocumentFailure_IsIgnored() + { + var fileAccess = new Mock(); + fileAccess.Setup(x => x.FindOpenDocument(It.IsAny())).Returns((ReviewDocument)null); + fileAccess.Setup(x => x.OpenDocumentAsync(It.IsAny())).ThrowsAsync(new IOException("open failed")); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, fileAccess.Object, () => "review-1", null); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { RepoRoot = RepoRoot, Path = "missing.cs", Result = EmptyReview() }); + + await Task.Delay(50); + Assert.IsEmpty(_events.Reviews); + } + + [TestMethod] + public async Task WatchReview_NullContent_PresentsWithoutCaching() + { + var document = Document("/repo/src/file.ts", null); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, true), () => "review-1", null); + + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(null)), + }); + + await WaitUntil(() => _events.Reviews.Count == 1); + Assert.IsNull(_events.Reviews[0].Content); + } + + [TestMethod] + public async Task WatchReview_SecondResultForPathReplacesCachedReview() + { + var document = Document("/repo/src/file.ts", "content"); + _pipeline.Dispose(); + _pipeline = new ReviewPipeline(_client.Object, _events, FileAccess(document, true), () => "review-1", null); + var notification = new ReviewNotification + { + RepoRoot = RepoRoot, + Path = "src/file.ts", + Result = EmptyReview(GitBlobSha.FromUtf8(document.Content)), + }; + + _client.Raise(x => x.ReviewReceived += null, _client.Object, notification); + await WaitUntil(() => _events.Reviews.Count == 1); + _client.Raise(x => x.ReviewReceived += null, _client.Object, notification); + + await WaitUntil(() => _events.Reviews.Count == 2); + Assert.HasCount(2, _events.Reviews); + } + + private void CompleteReview(string id, string repoRoot = RepoRoot, string path = "src/file.ts") + { + RaiseReview(id, repoRoot, path); + RaiseDelta(id, repoRoot, path, null); + } + + private void RaiseReview(string id, string repoRoot, string path) + { + _client.Raise( + x => x.ReviewReceived += null, + _client.Object, + new ReviewNotification { Id = id, RepoRoot = repoRoot, Path = path, Result = EmptyReview() }); + } + + private void RaiseDelta(string id, string repoRoot, string path, DeltaResponseModel result) + { + _client.Raise( + x => x.DeltaReceived += null, + _client.Object, + new DeltaNotification { Id = id, RepoRoot = repoRoot, Path = path, Result = result }); + } + + private ReviewDocument Document(string filePath, string content) + { + return new ReviewDocument { FilePath = filePath, Content = content, IsDirty = true }; + } + + private ReviewSubmission Submission(ReviewDocument document) + { + return new ReviewSubmission + { + Document = document, + RelPath = "src/file.ts", + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }; + } + + private CliReviewModel EmptyReview(string gitBlobSha = null) + { + return new CliReviewModel + { + FileLevelCodeSmells = new List(), + FunctionLevelCodeSmells = new List(), + RawScore = "raw", + GitBlobSha = gitBlobSha, + }; + } + + private IReviewPipelineFileAccess FileAccess(ReviewDocument document, bool visible) + { + var mock = new Mock(); + mock.Setup(x => x.FindOpenDocument(It.IsAny())).Returns(document); + mock.Setup(x => x.OpenDocumentAsync(It.IsAny())).ReturnsAsync(document); + mock.Setup(x => x.ReadFileBytesAsync(It.IsAny())) + .ReturnsAsync(System.Text.Encoding.UTF8.GetBytes(document.Content ?? string.Empty)); + mock.Setup(x => x.IsVisible(It.IsAny())).Returns(visible); + return mock.Object; + } + + private async Task WaitUntil(Func predicate) + { + for (var attempt = 0; attempt < 40; attempt++) + { + if (predicate()) + { + return; + } + + await Task.Delay(10); + } + + throw new TimeoutException("timed out waiting for pipeline presentation"); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs deleted file mode 100644 index 859c4131..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests - { - private Mock _mockLogger = null!; - private Mock _mockMapper = null!; - private Mock _mockExecutor = null!; - private Mock _mockTelemetryManager = null!; - private Mock _mockGitService = null!; - private CodeReviewer _codeReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - _mockMapper = new Mock(); - _mockExecutor = new Mock(); - _mockTelemetryManager = new Mock(); - _mockGitService = new Mock(); - - _codeReviewer = new CodeReviewer( - _mockLogger.Object, - _mockMapper.Object, - _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object); - } - - [TestMethod] - public async Task Test() - { - var path = "test.cs"; - var content = "public class Test { }"; - var reviewWithNullRawScore = new FileReviewModel { FilePath = path, RawScore = null, Score = 8.0f }; - var cliReview = new CliReviewModel { RawScore = null }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns("old code"); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", content, false, It.IsAny())).ReturnsAsync(cliReview); - _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(reviewWithNullRawScore); - - var (actualReview, actualDelta) = await _codeReviewer.ReviewWithDeltaAsync(path, content); - - Assert.IsNotNull(actualReview); - Assert.AreEqual(path, actualReview.FilePath); - Assert.IsNull(actualReview.RawScore); - Assert.IsNull(actualDelta); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny()), Times.Never); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_ValidRawScore_ReturnsDeltaTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_ValidRawScore_ReturnsDeltaTests.cs deleted file mode 100644 index 57da79d7..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_ValidRawScore_ReturnsDeltaTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Review; -using Moq; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class ReviewWithDeltaAsync_ValidRawScore_ReturnsDeltaTests - { - private Mock _mockLogger = null!; - private Mock _mockMapper = null!; - private Mock _mockExecutor = null!; - private Mock _mockTelemetryManager = null!; - private Mock _mockGitService = null!; - private CodeReviewer _codeReviewer = null!; - - [TestInitialize] - public void Setup() - { - _mockLogger = new Mock(); - _mockMapper = new Mock(); - _mockExecutor = new Mock(); - _mockTelemetryManager = new Mock(); - _mockGitService = new Mock(); - - _codeReviewer = new CodeReviewer( - _mockLogger.Object, - _mockMapper.Object, - _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object); - } - - [TestMethod] - public async Task Test() - { - var path = "test.cs"; - var content = "public class Test { }"; - var oldCode = "public class OldTest { }"; - var review = new FileReviewModel { FilePath = path, RawScore = "current-raw", Score = 8.5f }; - var cliReview = new CliReviewModel { RawScore = "current-raw" }; - var baselineCliReview = new CliReviewModel { RawScore = "baseline-raw" }; - var expectedDelta = new DeltaResponseModel { ScoreChange = 0.5m }; - - _mockGitService.Setup(x => x.GetFileContentForCommit(path, It.IsAny())).Returns(oldCode); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", content, false, It.IsAny())).ReturnsAsync(cliReview); - _mockExecutor.Setup(x => x.ReviewContentAsync("test.cs", oldCode, true, It.IsAny())).ReturnsAsync(baselineCliReview); - _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); - _mockMapper.Setup(x => x.Map(path, baselineCliReview)).Returns(new FileReviewModel { FilePath = path, RawScore = "baseline-raw" }); - _mockExecutor.Setup(x => x.ReviewDeltaAsync(It.IsAny(), It.IsAny())).ReturnsAsync(expectedDelta); - - var (actualReview, actualDelta) = await _codeReviewer.ReviewWithDeltaAsync(path, content); - - Assert.IsNotNull(actualReview); - Assert.AreEqual("current-raw", actualReview.RawScore); - Assert.IsNotNull(actualDelta); - Assert.AreEqual(0.5m, actualDelta.ScoreChange); - _mockExecutor.Verify(x => x.ReviewDeltaAsync(It.Is(r => r.NewScore == "current-raw" && r.OldScore == "baseline-raw"), It.IsAny()), Times.Once); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcJsonNormalizerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcJsonNormalizerTests.cs new file mode 100644 index 00000000..f02fb7b9 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcJsonNormalizerTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +using Newtonsoft.Json.Linq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class RpcJsonNormalizerTests + { + [TestMethod] + public void ToKebabCaseName_ConvertsCamelCaseWithoutSplittingDigits() + { + Assert.AreEqual("git-blob-sha", RpcJsonNormalizer.ToKebabCaseName("gitBlobSha")); + Assert.AreEqual("nippy-b64", RpcJsonNormalizer.ToKebabCaseName("nippyB64")); + Assert.AreEqual("file-level-code-smells", RpcJsonNormalizer.ToKebabCaseName("fileLevelCodeSmells")); + } + + [TestMethod] + public void ToKebabCaseName_LeavesKebabCaseUnchanged() + { + Assert.AreEqual("repo-root", RpcJsonNormalizer.ToKebabCaseName("repo-root")); + Assert.AreEqual("nippy-b64", RpcJsonNormalizer.ToKebabCaseName("nippy-b-64")); + } + + [TestMethod] + public void ToKebabCase_NormalizesNestedObjectsAndArrays() + { + var input = JObject.Parse(@"{ + ""repoRoot"": ""C:/repo"", + ""result"": { + ""gitBlobSha"": ""abc"", + ""fileLevelCodeSmells"": [{ ""highlightRange"": { ""startLine"": 1 } }] + } + }"); + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(input); + + Assert.AreEqual("C:/repo", (string)normalized["repo-root"]); + Assert.AreEqual("abc", (string)normalized["result"]["git-blob-sha"]); + Assert.AreEqual(1, (int)normalized["result"]["file-level-code-smells"][0]["highlight-range"]["start-line"]); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs new file mode 100644 index 00000000..f125c783 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Runtime.InteropServices; +using System.Text; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class RpcPathTests + { + [TestMethod] + public void RememberLocalRepoRoot_IgnoresNullOrEmpty() + { + RpcPath.RememberLocalRepoRoot(null); + RpcPath.RememberLocalRepoRoot(string.Empty); + } + + [TestMethod] + public void ToLocalRepoRoot_EmptyInputs_ReturnEmpty() + { + Assert.AreEqual(string.Empty, RpcPath.ToLocalRepoRoot(null)); + Assert.AreEqual(string.Empty, RpcPath.ToLocalRepoRoot(string.Empty)); + } + + [TestMethod] + public void NormalizeFsPath_InvalidPath_FallsBackWithoutThrowing() + { + var result = RpcPath.NormalizeFsPath("::not-a-valid-path::"); + Assert.IsFalse(string.IsNullOrEmpty(result)); + } + + [TestMethod] + public void RelativePosixAndCombineRepo_NormalizeSeparators() + { + var repoRoot = @"C:\repo"; + Assert.AreEqual("src/a.cs", RpcPath.RelativePosix(repoRoot, @"C:\repo\src\a.cs")); + Assert.AreEqual(Path.Combine(repoRoot, "src", "a.cs"), RpcPath.CombineRepo(repoRoot, "src/a.cs")); + Assert.AreEqual("a/b.cs", RpcPath.ToPosixRelPath(@"a\b.cs")); + } + + [TestMethod] + public void CanonicalRepoRoot_NonExistentPath_FallsBackToNormalizeFsPath() + { + var missing = Path.Combine(Path.GetTempPath(), "rpc-path-missing-" + Guid.NewGuid().ToString("N")); + var canonical = RpcPath.CanonicalRepoRoot(missing); + Assert.AreEqual(RpcPath.NormalizeFsPath(missing), canonical); + } + + [TestMethod] + public void CanonicalRepoRoot_SecondCall_UsesCachedValue() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "rpc-path-cache-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + + try + { + var first = RpcPath.CanonicalRepoRoot(repoRoot); + var second = RpcPath.CanonicalRepoRoot(repoRoot); + Assert.AreEqual(first, second); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + + [TestMethod] + public void ToLocalRepoRoot_RoundTripsRememberedRoot() + { + var repoRoot = Path.Combine(Path.GetTempPath(), "rpc-path-roundtrip-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + + try + { + RpcPath.RememberLocalRepoRoot(repoRoot); + var canonical = RpcPath.CanonicalRepoRoot(repoRoot); + Assert.AreEqual(repoRoot, RpcPath.ToLocalRepoRoot(canonical)); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + + [TestMethod] + public void CanonicalRepoRoot_ExistingDirectory_OnWindows_ResolvesPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive(); + return; + } + + var repoRoot = Path.Combine(Path.GetTempPath(), "rpc-path-resolve-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + + try + { + var canonical = RpcPath.CanonicalRepoRoot(repoRoot); + Assert.IsFalse(string.IsNullOrEmpty(canonical)); + Assert.AreEqual(canonical, RpcPath.CanonicalRepoRoot(repoRoot)); + } + finally + { + Directory.Delete(repoRoot, true); + } + } + + [TestMethod] + public void PathsEqual_WindowsShortPathMatchesLongPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive(); + return; + } + + var longPath = Path.Combine(Path.GetTempPath(), "rpc-path-long-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(longPath); + + try + { + var shortPath = GetShortPath(longPath); + Assert.IsTrue(RpcPath.PathsEqual(longPath, shortPath)); + Assert.AreEqual(RpcPath.CanonicalRepoRoot(longPath), RpcPath.CanonicalRepoRoot(shortPath)); + } + finally + { + Directory.Delete(longPath, true); + } + } + + private static string GetShortPath(string longPath) + { + var buffer = new StringBuilder(512); + var length = GetShortPathName(longPath, buffer, (uint)buffer.Capacity); + if (length == 0 || length >= buffer.Capacity) + { + return longPath; + } + + return buffer.ToString(); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern uint GetShortPathName(string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SavedFilesTrackerCoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SavedFilesTrackerCoreTests.cs deleted file mode 100644 index 65cd10d0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SavedFilesTrackerCoreTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class SavedFilesTrackerCoreTests - { - private SavedFilesTrackerCore _tracker; - private FakeDocumentSaveEventSource _fakeEventSource; - private FakeOpenFilesObserver _fakeOpenFilesObserver; - private FakeLogger _fakeLogger; - - [TestInitialize] - public void Setup() - { - _fakeEventSource = new FakeDocumentSaveEventSource(); - _fakeOpenFilesObserver = new FakeOpenFilesObserver(); - _fakeLogger = new FakeLogger(); - - _tracker = new SavedFilesTrackerCore(_fakeEventSource, _fakeOpenFilesObserver, _fakeLogger); - } - - [TestCleanup] - public void Cleanup() - { - _tracker?.Dispose(); - } - - [TestMethod] - public void GetSavedFiles_ReturnsDefensiveCopy() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file1.cs"); - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file2.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file1.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file2.cs"); - - var result1 = _tracker.GetSavedFiles(); - var result2 = _tracker.GetSavedFiles(); - - Assert.AreNotSame(result1, result2, "Should return different instances"); - - var list1 = result1.ToList(); - var list2 = result2.ToList(); - - Assert.HasCount(2, list1, "First result should have 2 files"); - Assert.HasCount(2, list2, "Second result should have 2 files"); - - Assert.Contains(@"C:\test\file1.cs", list1, "First result should contain file1.cs"); - Assert.Contains(@"C:\test\file2.cs", list1, "First result should contain file2.cs"); - Assert.Contains(@"C:\test\file1.cs", list2, "Second result should contain file1.cs"); - Assert.Contains(@"C:\test\file2.cs", list2, "Second result should contain file2.cs"); - - var beforeCount = _tracker.GetSavedFiles().Count(); - list1.Clear(); - var afterCount = _tracker.GetSavedFiles().Count(); - - Assert.AreEqual(beforeCount, afterCount, "Modifying returned list should not affect internal state"); - Assert.AreEqual(2, afterCount, "Internal state should still have 2 files after clearing returned list"); - } - - [TestMethod] - // isVisible expectedCount - [DataRow(true, 1, DisplayName = "Tracks visible files")] - [DataRow(false, 0, DisplayName = "Does not track invisible files")] - public void OnDocumentSaved_TracksBasedOnVisibility(bool isVisible, int expectedCount) - { - var filePath = @"C:\test\file.cs"; - - if (isVisible) - { - _fakeOpenFilesObserver.AddOpenFile(filePath); - } - - _fakeEventSource.SimulateSave(filePath); - - var result = _tracker.GetSavedFiles(); - - Assert.AreEqual(expectedCount, result.Count()); - } - - [TestMethod] - public void ClearSavedFiles_RemovesAllTrackedFiles() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file1.cs"); - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file2.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file1.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file2.cs"); - - Assert.AreEqual(2, _tracker.GetSavedFiles().Count()); - - _tracker.ClearSavedFiles(); - - Assert.AreEqual(0, _tracker.GetSavedFiles().Count(), "Should have no tracked files after clear"); - } - - [TestMethod] - public void RemoveFromTracker_RemovesSpecificFile() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file1.cs"); - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file2.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file1.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file2.cs"); - - _tracker.RemoveFromTracker(@"C:\test\file1.cs"); - - var result = _tracker.GetSavedFiles().ToList(); - Assert.HasCount(1, result); - Assert.AreEqual(@"C:\test\file2.cs", result[0]); - } - - [TestMethod] - public void OnDocumentSaved_IgnoresNullOrEmptyPath() - { - _fakeEventSource.SimulateSave(null); - _fakeEventSource.SimulateSave(string.Empty); - - var result = _tracker.GetSavedFiles(); - - Assert.AreEqual(0, result.Count(), "Should ignore null or empty paths"); - } - - [TestMethod] - public void RemoveFromTracker_HandlesNullOrEmptyPath() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file1.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file1.cs"); - - _tracker.RemoveFromTracker(null); - _tracker.RemoveFromTracker(string.Empty); - - Assert.AreEqual(1, _tracker.GetSavedFiles().Count(), "Should still have the original file"); - } - - [TestMethod] - public void GetSavedFiles_ThreadSafe() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file.cs"); - - var tasks = new List(); - for (int i = 0; i < 100; i++) - { - tasks.Add(Task.Run(() => - { - _fakeEventSource.SimulateSave(@"C:\test\file.cs"); - _ = _tracker.GetSavedFiles().ToList(); - })); - } - - Task.WaitAll(tasks.ToArray()); - - var result = _tracker.GetSavedFiles(); - Assert.AreEqual(1, result.Count(), "Should have exactly one tracked file despite concurrent access"); - } - - [TestMethod] - public void OnDocumentSaved_IsCaseInsensitive() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\TEST\File.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file.cs"); - - var result = _tracker.GetSavedFiles(); - - Assert.AreEqual(1, result.Count(), "Should track file with case-insensitive matching"); - } - - [TestMethod] - public void OnDocumentSaved_DoesNotDuplicateFiles() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file.cs"); - _fakeEventSource.SimulateSave(@"C:\TEST\FILE.CS"); - - var result = _tracker.GetSavedFiles(); - - Assert.AreEqual(1, result.Count(), "Should not duplicate files with case-insensitive comparison"); - } - - [TestMethod] - public void Start_CallsEventSourceStart() - { - Assert.IsFalse(_fakeEventSource.StartCalled, "Start should not be called initially"); - - _tracker.Start(); - - Assert.IsTrue(_fakeEventSource.StartCalled, "Start should be called on event source"); - } - - [TestMethod] - public void Dispose_CalledTwice_DoesNotThrow() - { - _fakeOpenFilesObserver.AddOpenFile(@"C:\test\file.cs"); - _fakeEventSource.SimulateSave(@"C:\test\file.cs"); - - _tracker.Dispose(); - _tracker.Dispose(); - } - } - - public class FakeDocumentSaveEventSource : IDocumentSaveEventSource - { - public event EventHandler DocumentSaved; - - public bool StartCalled { get; private set; } - - public void Start() - { - StartCalled = true; - } - - public void SimulateSave(string filePath) - { - DocumentSaved?.Invoke(this, filePath); - } - - public void Dispose() - { - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SerializationTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SerializationTests.cs index 078d8ceb..bbda2950 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SerializationTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SerializationTests.cs @@ -94,29 +94,6 @@ public void FnToRefactorModel_Serialize_RoundTrip_PreservesData() AssertFnToRefactorRoundTrip(model, deserialized); } - [TestMethod] - public void ReviewRequestModel_Serialize_ProducesCorrectPropertyNames() - { - var model = new ReviewRequestModel { FilePath = "test.cs", FileContent = "public class Test {}", CachePath = "/cache/path" }; - - var json = JsonConvert.SerializeObject(model); - - AssertJsonContainsProperties(json, "path", "file-content", "cache-path"); - } - - [TestMethod] - public void ReviewRequestModel_RoundTrip_PreservesData() - { - var model = new ReviewRequestModel { FilePath = "test.cs", FileContent = "code content", CachePath = "/cache" }; - - var json = JsonConvert.SerializeObject(model); - var deserialized = JsonConvert.DeserializeObject(json); - - Assert.AreEqual(model.FilePath, deserialized?.FilePath); - Assert.AreEqual(model.FileContent, deserialized?.FileContent); - Assert.AreEqual(model.CachePath, deserialized?.CachePath); - } - [TestMethod] public void CliRangeModel_Deserialize_WithKebabCaseProperties() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ServerWorkerThreadsTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ServerWorkerThreadsTests.cs new file mode 100644 index 00000000..52eb9304 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ServerWorkerThreadsTests.cs @@ -0,0 +1,23 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ServerWorkerThreadsTests + { + [TestMethod] + public void Resolve_WhenConfiguredPositive_ReturnsConfiguredValue() + { + Assert.AreEqual(4, ServerWorkerThreads.Resolve(4)); + } + + [TestMethod] + public void Resolve_WhenUnset_ReturnsAtLeastOneWorker() + { + Assert.IsGreaterThanOrEqualTo(1, ServerWorkerThreads.Resolve(0)); + Assert.IsGreaterThanOrEqualTo(1, ServerWorkerThreads.Resolve(-1)); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs index 0309e76f..cf7fab6b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs @@ -7,6 +7,7 @@ using Codescene.VSExtension.Core.Interfaces.Telemetry; using Codescene.VSExtension.Core.Util; using Moq; +using Newtonsoft.Json; namespace Codescene.VSExtension.Core.Tests { @@ -14,9 +15,8 @@ namespace Codescene.VSExtension.Core.Tests public class TelemetryManagerTests { private Mock _mockLogger; - private Mock _mockExecutor; + private Mock _mockClient; private Mock _mockDeviceIdStore; - private Mock _mockCommandProvider; private Mock _mockMetadataProvider; private TelemetryManager _telemetryManager; @@ -24,23 +24,42 @@ public class TelemetryManagerTests public void Setup() { _mockLogger = new Mock(); - _mockExecutor = new Mock(); + _mockClient = new Mock(); _mockDeviceIdStore = new Mock(); - _mockCommandProvider = new Mock(); _mockMetadataProvider = new Mock(); _mockMetadataProvider.Setup(x => x.GetEditorVersion()).Returns("18.1.1"); + _mockClient.Setup(x => x.TelemetryAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); _telemetryManager = new TelemetryManager( _mockLogger.Object, - _mockExecutor.Object, + _mockClient.Object, _mockDeviceIdStore.Object, - _mockCommandProvider.Object, _mockMetadataProvider.Object); ErrorTelemetryUtils.ResetErrorCount(); TelemetryUtils.TelemetryEnabledOverrideForTests = true; } + [TestMethod] + public async Task SendTelemetryAsync_WhenEnableTelemetryIsFalse_DoesNotCallCli() + { + TelemetryUtils.TelemetryEnabledOverrideForTests = null; + var settings = new Mock(); + settings.Setup(x => x.EnableTelemetry).Returns(false); + var manager = new TelemetryManager( + _mockLogger.Object, + _mockClient.Object, + _mockDeviceIdStore.Object, + _mockMetadataProvider.Object, + settings.Object); + + await manager.SendTelemetryAsync("event"); + + _mockDeviceIdStore.Verify(x => x.GetDeviceIdAsync(It.IsAny()), Times.Never); + _mockClient.Verify(x => x.TelemetryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [TestCleanup] public void TearDown() { @@ -51,18 +70,14 @@ public void TearDown() [TestMethod] public async Task SendTelemetry_WhenExceptionThrown_LogsDebugAndDoesNotRethrow() { - // Arrange var eventName = "test-event"; _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("telemetry command"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _mockClient.Setup(x => x.TelemetryAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Telemetry failed")); - // Act - should not throw await _telemetryManager.SendTelemetryAsync(eventName); - // Assert _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("Unable to send telemetry"))), Times.Once); } @@ -71,7 +86,6 @@ public async Task SendTelemetry_WhenExceptionThrown_LogsDebugAndDoesNotRethrow() [TestMethod] public async Task SendTelemetryAsync_WithAdditionalData_DoesNotThrow() { - // Arrange var eventName = "test-event"; var additionalData = new Dictionary { @@ -81,90 +95,62 @@ public async Task SendTelemetryAsync_WithAdditionalData_DoesNotThrow() _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("telemetry command"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("success"); - // Act - should not throw await _telemetryManager.SendTelemetryAsync(eventName, additionalData); - - // Assert - no exception means success - // Note: Actual execution depends on TelemetryUtils.IsTelemetryEnabled() which reads registry } [TestMethod] public async Task SendTelemetryAsync_GetsDeviceIdFromStore() { - // Arrange var eventName = "test-event"; _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("my-device-id"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("2.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("cmd"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("ok"); - // Act await _telemetryManager.SendTelemetryAsync(eventName); - - // Assert - device ID store should be called (if telemetry is enabled) - // Note: This verification depends on TelemetryUtils.IsTelemetryEnabled() returning true } [TestMethod] public async Task SendTelemetryAsync_IncludesEditorVersionInPayload() { var eventName = "test-event"; - string capturedCommand = null; + object capturedEvent = null; TelemetryUtils.TelemetryEnabledOverrideForTests = true; _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); _mockMetadataProvider.Setup(x => x.GetEditorVersion()).Returns("18.1.1"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())) - .Callback(json => capturedCommand = json) - .Returns("cmd"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("ok"); + _mockClient.Setup(x => x.TelemetryAsync(It.IsAny(), It.IsAny())) + .Callback((payload, _) => capturedEvent = payload) + .Returns(Task.CompletedTask); await _telemetryManager.SendTelemetryAsync(eventName); - Assert.IsNotNull(capturedCommand); - Assert.Contains("\"editor-version\":\"18.1.1\"", capturedCommand); + Assert.IsNotNull(capturedEvent); + var json = JsonConvert.SerializeObject(capturedEvent); + Assert.Contains("\"editor-version\":\"18.1.1\"", json); } [TestMethod] public async Task SendTelemetryAsync_GetsVersionFromMetadataProvider() { - // Arrange var eventName = "test-event"; _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("3.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("cmd"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("ok"); - // Act await _telemetryManager.SendTelemetryAsync(eventName); - - // Assert - metadata provider should be called (if telemetry is enabled) - // Note: This verification depends on TelemetryUtils.IsTelemetryEnabled() returning true } #if DEBUG [TestMethod] public async Task SendErrorTelemetry_WhenExceptionThrown_LogsDebugAndDoesNotRethrow() { - // Arrange var ex = new InvalidOperationException("Test error"); _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("telemetry command"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _mockClient.Setup(x => x.TelemetryAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Telemetry failed")); - // Act - should not throw await _telemetryManager.SendErrorTelemetryAsync(ex, "Test context"); - // Assert - SendErrorTelemetry calls SendTelemetry which has its own exception handling _mockLogger.Verify(l => l.Debug(It.Is(s => s.Contains("Unable to send telemetry"))), Times.Once); } @@ -173,7 +159,6 @@ public async Task SendErrorTelemetry_WhenExceptionThrown_LogsDebugAndDoesNotReth [TestMethod] public async Task SendErrorTelemetry_WithExtraData_DoesNotThrow() { - // Arrange var ex = new Exception("Test error"); var extraData = new Dictionary { @@ -183,52 +168,34 @@ public async Task SendErrorTelemetry_WithExtraData_DoesNotThrow() _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("telemetry command"); - _mockExecutor.Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("success"); - // Act - should not throw await _telemetryManager.SendErrorTelemetryAsync(ex, "Test context", extraData); - - // Assert - no exception means success } [TestMethod] public async Task SendErrorTelemetry_TelemetryRelatedError_DoesNotSend() { - // Arrange var ex = new Exception("Failed to send telemetry"); _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("cmd"); - // Act await _telemetryManager.SendErrorTelemetryAsync(ex, "context"); - // Assert - executor should not be called for telemetry-related errors - _mockExecutor.Verify( - x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); + _mockClient.Verify(x => x.TelemetryAsync(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] public async Task SendErrorTelemetry_NetworkError_DoesNotSend() { - // Arrange var ex = new Exception("ECONNREFUSED: Connection refused"); _mockDeviceIdStore.Setup(x => x.GetDeviceIdAsync()).ReturnsAsync("device-123"); _mockMetadataProvider.Setup(x => x.GetVersion()).Returns("1.0.0"); - _mockCommandProvider.Setup(x => x.SendTelemetryCommand(It.IsAny())).Returns("cmd"); - // Act await _telemetryManager.SendErrorTelemetryAsync(ex, "context"); - // Assert - executor should not be called for network errors - _mockExecutor.Verify( - x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); + _mockClient.Verify(x => x.TelemetryAsync(It.IsAny(), It.IsAny()), Times.Never); } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TestableGitChangeLister.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TestableGitChangeLister.cs deleted file mode 100644 index faad87a0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TestableGitChangeLister.cs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Util; - -namespace Codescene.VSExtension.Core.Tests -{ - public class TestableGitChangeLister : GitChangeLister - { - public TestableGitChangeLister( - ISavedFilesTracker savedFilesTracker, - ISupportedFileChecker supportedFileChecker, - ILogger logger, - IGitService gitService, - IIdeActivityTracker ideActivityTracker) - : base(savedFilesTracker, supportedFileChecker, logger, gitService, ideActivityTracker: ideActivityTracker) - { - } - - public TestableGitChangeLister( - ISavedFilesTracker savedFilesTracker, - ISupportedFileChecker supportedFileChecker, - ILogger logger, - IGitService gitService, - int? pollingInterval = null, - IIdeActivityTracker ideActivityTracker = null, - ICpuUsageChecker cpuUsageChecker = null) - : base(savedFilesTracker, supportedFileChecker, logger, gitService, pollingInterval, ideActivityTracker, cpuUsageChecker) - { - } - - public bool ThrowInGetAllChangedFilesAsync { get; set; } - - public TimeSpan? SimulatedScanDuration { get; set; } - - public async Task InvokePeriodicScanAsync() - { - var method = typeof(GitChangeLister).GetMethod("PeriodicScanAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new[] { typeof(CancellationToken) }, null); - var task = (Task)method.Invoke(this, new object[] { CancellationToken.None }); - await task; - } - - public HashSet InvokeConvertAndFilterPaths(IEnumerable relativePaths, string gitRootPath) - { - return ConvertAndFilterPaths(relativePaths, gitRootPath); - } - - public override async Task> GetAllChangedFilesAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - if (ThrowInGetAllChangedFilesAsync) - { - throw new Exception("Simulated exception in CollectFilesFromRepoStateAsync"); - } - - return await base.GetAllChangedFilesAsync(gitRootPath, workspacePath, cancellationToken); - } - - public override async Task> GetAllChangedFilesAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - if (ThrowInGetAllChangedFilesAsync) - { - throw new Exception("Simulated exception in CollectFilesFromRepoStateAsync"); - } - - if (SimulatedScanDuration.HasValue) - { - await Task.Delay(SimulatedScanDuration.Value, cancellationToken); - } - - return await base.GetAllChangedFilesAsync(gitRootPath, workspacePaths, cancellationToken); - } - - public DroppingScheduledExecutor? GetScheduledExecutorForTesting() - { - var field = typeof(GitChangeLister).GetField("_scheduledExecutor", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return field?.GetValue(this) as DroppingScheduledExecutor; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TextUtilsTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TextUtilsTests.cs index cd354c0d..1b0f9996 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TextUtilsTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TextUtilsTests.cs @@ -67,6 +67,25 @@ public void NormalizeLineEndings_MixedLineEndings_ReturnsNormalized() Assert.AreEqual(expected, TextUtils.NormalizeLineEndings(input)); } + [TestMethod] + public void NormalizeLineEndings_LfDocument_KeepsSnippetOnLf() + { + var document = "line1\nline2\n"; + var snippet = "refactored\r\nblock\r\n"; + + var ending = TextUtils.DominantLineEnding(document); + var normalized = TextUtils.NormalizeLineEndings(snippet, ending); + + Assert.AreEqual("\n", ending); + Assert.AreEqual("refactored\nblock\n", normalized); + } + + [TestMethod] + public void DominantLineEnding_CrlfDocument_ReturnsCrlf() + { + Assert.AreEqual("\r\n", TextUtils.DominantLineEnding("line1\r\nline2\r\n")); + } + [TestMethod] public void TrimForLogging_NullInput_ReturnsNull() => Assert.IsNull(TextUtils.TrimForLogging(null)); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/UntrackedFileProcessorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/UntrackedFileProcessorTests.cs deleted file mode 100644 index 94bb0fc8..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/UntrackedFileProcessorTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Application.Git; - -namespace Codescene.VSExtension.Core.Tests -{ - [TestClass] - public class UntrackedFileProcessorTests - { - private UntrackedFileProcessor _processor; - - [TestInitialize] - public void Setup() - { - _processor = new UntrackedFileProcessor(); - } - - [TestMethod] - public void AddUntrackedFileToDirectory_FileInSubdirectory_AddsToCorrectDirectoryKey() - { - var untrackedByDirectory = new Dictionary>(); - var relativePath = @"src\Utils\Helper.cs"; - var absolutePath = @"C:\project\src\Utils\Helper.cs"; - - _processor.AddUntrackedFileToDirectory(relativePath, absolutePath, untrackedByDirectory); - - Assert.IsTrue(untrackedByDirectory.ContainsKey(@"src\Utils")); - Assert.HasCount(1, untrackedByDirectory[@"src\Utils"]); - Assert.AreEqual(absolutePath, untrackedByDirectory[@"src\Utils"][0]); - } - - [TestMethod] - public void AddUntrackedFileToDirectory_FileInRoot_UsesRootKey() - { - var untrackedByDirectory = new Dictionary>(); - var relativePath = "README.md"; - var absolutePath = @"C:\project\README.md"; - - _processor.AddUntrackedFileToDirectory(relativePath, absolutePath, untrackedByDirectory); - - Assert.IsTrue(untrackedByDirectory.ContainsKey("__root__")); - Assert.HasCount(1, untrackedByDirectory["__root__"]); - Assert.AreEqual(absolutePath, untrackedByDirectory["__root__"][0]); - } - - [TestMethod] - public void AddUntrackedFileToDirectory_MultipleFilesSameDirectory_AppendsToExistingList() - { - var untrackedByDirectory = new Dictionary>(); - var relativePath1 = @"src\File1.cs"; - var absolutePath1 = @"C:\project\src\File1.cs"; - var relativePath2 = @"src\File2.cs"; - var absolutePath2 = @"C:\project\src\File2.cs"; - - _processor.AddUntrackedFileToDirectory(relativePath1, absolutePath1, untrackedByDirectory); - _processor.AddUntrackedFileToDirectory(relativePath2, absolutePath2, untrackedByDirectory); - - Assert.HasCount(1, untrackedByDirectory); - Assert.IsTrue(untrackedByDirectory.ContainsKey("src")); - Assert.HasCount(2, untrackedByDirectory["src"]); - Assert.AreEqual(absolutePath1, untrackedByDirectory["src"][0]); - Assert.AreEqual(absolutePath2, untrackedByDirectory["src"][1]); - } - - [TestMethod] - public void AddUntrackedFileToDirectory_MultipleFilesDifferentDirectories_CreatesSeparateEntries() - { - var untrackedByDirectory = new Dictionary>(); - var relativePath1 = @"src\File1.cs"; - var absolutePath1 = @"C:\project\src\File1.cs"; - var relativePath2 = @"tests\Test1.cs"; - var absolutePath2 = @"C:\project\tests\Test1.cs"; - - _processor.AddUntrackedFileToDirectory(relativePath1, absolutePath1, untrackedByDirectory); - _processor.AddUntrackedFileToDirectory(relativePath2, absolutePath2, untrackedByDirectory); - - Assert.HasCount(2, untrackedByDirectory); - Assert.IsTrue(untrackedByDirectory.ContainsKey("src")); - Assert.IsTrue(untrackedByDirectory.ContainsKey("tests")); - Assert.HasCount(1, untrackedByDirectory["src"]); - Assert.HasCount(1, untrackedByDirectory["tests"]); - } - - [TestMethod] - public void ProcessUntrackedDirectories_DirectoryWithFiveFiles_AllFilesAdded() - { - var untrackedByDirectory = new Dictionary> - { - { - "src", new List - { - @"C:\project\src\File1.cs", - @"C:\project\src\File2.cs", - @"C:\project\src\File3.cs", - @"C:\project\src\File4.cs", - @"C:\project\src\File5.cs", - } - }, - }; - var savedFiles = new HashSet(); - var changedFiles = new HashSet(); - - _processor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); - - Assert.HasCount(5, changedFiles); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File1.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File5.cs"); - } - - [TestMethod] - public void ProcessUntrackedDirectories_DirectoryWithSixFiles_OnlySavedFilesAdded() - { - var untrackedByDirectory = new Dictionary> - { - { - "src", new List - { - @"C:\project\src\File1.cs", - @"C:\project\src\File2.cs", - @"C:\project\src\File3.cs", - @"C:\project\src\File4.cs", - @"C:\project\src\File5.cs", - @"C:\project\src\File6.cs", - } - }, - }; - var savedFiles = new HashSet - { - @"C:\project\src\File2.cs", - @"C:\project\src\File5.cs", - }; - var changedFiles = new HashSet(); - - _processor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); - - Assert.HasCount(2, changedFiles); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File2.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File5.cs"); - CollectionAssert.DoesNotContain(changedFiles.ToList(), @"C:\project\src\File1.cs"); - } - - [TestMethod] - public void ProcessUntrackedDirectories_DirectoryWithSixFilesNoneInSavedFiles_NoFilesAdded() - { - var untrackedByDirectory = new Dictionary> - { - { - "src", new List - { - @"C:\project\src\File1.cs", - @"C:\project\src\File2.cs", - @"C:\project\src\File3.cs", - @"C:\project\src\File4.cs", - @"C:\project\src\File5.cs", - @"C:\project\src\File6.cs", - } - }, - }; - var savedFiles = new HashSet(); - var changedFiles = new HashSet(); - - _processor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); - - Assert.IsEmpty(changedFiles); - } - - [TestMethod] - public void ProcessUntrackedDirectories_MultipleDirectoriesWithMixedCounts_EachProcessedIndependently() - { - var untrackedByDirectory = new Dictionary> - { - { - "src", new List - { - @"C:\project\src\File1.cs", - @"C:\project\src\File2.cs", - @"C:\project\src\File3.cs", - } - }, - { - "tests", new List - { - @"C:\project\tests\Test1.cs", - @"C:\project\tests\Test2.cs", - @"C:\project\tests\Test3.cs", - @"C:\project\tests\Test4.cs", - @"C:\project\tests\Test5.cs", - @"C:\project\tests\Test6.cs", - } - }, - { - "__root__", new List - { - @"C:\project\README.md", - } - }, - }; - var savedFiles = new HashSet - { - @"C:\project\tests\Test2.cs", - }; - var changedFiles = new HashSet(); - - _processor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); - - Assert.HasCount(5, changedFiles); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File1.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File2.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\src\File3.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\tests\Test2.cs"); - CollectionAssert.Contains(changedFiles.ToList(), @"C:\project\README.md"); - CollectionAssert.DoesNotContain(changedFiles.ToList(), @"C:\project\tests\Test1.cs"); - } - - [TestMethod] - public void ProcessUntrackedDirectories_EmptyDictionary_NoChangesToChangedFiles() - { - var untrackedByDirectory = new Dictionary>(); - var savedFiles = new HashSet(); - var changedFiles = new HashSet(); - - _processor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); - - Assert.IsEmpty(changedFiles); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorGitIgnoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorGitIgnoreTests.cs new file mode 100644 index 00000000..218be594 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorGitIgnoreTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class WorkspaceWatchCoordinatorGitIgnoreTests + { + [TestMethod] + public async Task ApplyInventory_PrunesMonitor_SkipsIgnoredDirtyBuffers() + { + var repo = CreateGitRepo(); + var ignoredDirtyPath = Path.Combine(repo, "ignored-dirty.cs"); + var dirtyPath = Path.Combine(repo, "dirty.cs"); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var git = new Mock(); + git.Setup(x => x.IsFileIgnored(ignoredDirtyPath)).Returns(true); + git.Setup(x => x.IsFileIgnored(dirtyPath)).Returns(false); + ISet keep = null; + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = ignoredDirtyPath, Content = "x", IsDirty = true }, + new ReviewDocument { FilePath = dirtyPath, Content = "y", IsDirty = true }, + }); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback, ISet>((_, paths) => keep = paths); + + using (var coordinator = new WorkspaceWatchCoordinator( + client.Object, pipeline.Object, null, host.Object, null, git.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "tracked.cs" } }); + + Assert.IsNotNull(keep); + Assert.IsTrue(keep.Any(path => path.EndsWith("dirty.cs", StringComparison.OrdinalIgnoreCase))); + Assert.IsFalse(keep.Any(path => path.EndsWith("ignored-dirty.cs", StringComparison.OrdinalIgnoreCase))); + } + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task ServerRestart_DoesNotSeedIgnoredDirtyBuffers() + { + var repo = CreateGitRepo(); + var ignoredDirtyPath = Path.Combine(repo, "ignored-dirty.cs"); + File.WriteAllText(ignoredDirtyPath, "x"); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var git = new Mock(); + git.Setup(x => x.IsFileIgnored(ignoredDirtyPath)).Returns(true); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = ignoredDirtyPath, Content = "x", IsDirty = true }, + }); + + using (var coordinator = new WorkspaceWatchCoordinator( + client.Object, pipeline.Object, null, host.Object, null, git.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + pipeline.Invocations.Clear(); + client.Raise(x => x.ServerStarted += null, client.Object, new ServerStartEvent { Restart = true }); + await Task.Delay(50); + } + + pipeline.Verify( + x => x.SubmitBatchAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task AdmitsDeltaAsync_RejectsIgnoredDirtyPathNotInInventory() + { + var repo = CreateGitRepo(); + var ignoredDirtyPath = Path.Combine(repo, "ignored-dirty.cs"); + File.WriteAllText(ignoredDirtyPath, "x"); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var git = new Mock(); + git.Setup(x => x.IsFileIgnored(ignoredDirtyPath)).Returns(true); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = ignoredDirtyPath, Content = "x", IsDirty = true }, + }); + + using (var coordinator = new WorkspaceWatchCoordinator( + client.Object, pipeline.Object, null, host.Object, null, git.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); + + Assert.IsFalse(await coordinator.AdmitsDeltaAsync(repo, "ignored-dirty.cs")); + } + } + finally + { + Directory.Delete(repo, true); + } + } + + private static string CreateGitRepo() + { + var path = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + LibGit2Sharp.Repository.Init(path); + return path; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorNestedRepoTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorNestedRepoTests.cs new file mode 100644 index 00000000..40c97edb --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorNestedRepoTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class WorkspaceWatchCoordinatorNestedRepoTests + { + [TestMethod] + public async Task Sync_FolderAboveRepository_WatchesTheNestedRepoOnly() + { + var parent = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); + var repo = Path.Combine(parent, "nested"); + Directory.CreateDirectory(repo); + LibGit2Sharp.Repository.Init(repo); + var modules = Path.Combine(parent, "node_modules"); + Directory.CreateDirectory(modules); + LibGit2Sharp.Repository.Init(modules); + Directory.CreateDirectory(Path.Combine(parent, "docs")); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { parent }); + } + + client.Verify( + x => x.WatchFiles( + It.Is(root => RpcPath.PathsEqual(root, repo)), + It.Is>(paths => paths == null)), + Times.Once); + client.Verify( + x => x.WatchFiles(It.IsAny(), It.IsAny>()), + Times.Once); + } + finally + { + Directory.Delete(parent, true); + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorQueueTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorQueueTests.cs new file mode 100644 index 00000000..b96b10ec --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorQueueTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class WorkspaceWatchCoordinatorQueueTests + { + [TestMethod] + public async Task QueueChanged_WithPendingWork_DoesNotIdlePrune() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var pruneCount = 0; + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback(() => pruneCount++); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "tracked.cs" } }); + var afterInventory = pruneCount; + + client.Raise( + x => x.QueueChanged += null, + client.Object, + new ReviewQueue { Count = 2, Files = new[] { "a.cs", "b.cs" } }); + await Task.Delay(WorkspaceWatchCoordinator.QueueIdlePruneDelayMs + 150); + + Assert.AreEqual(afterInventory, pruneCount); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task AdmitsDeltaAsync_WhenInventoryNotLoadedYet_AdmitsWithoutRefresh() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + + Assert.IsTrue(await coordinator.AdmitsDeltaAsync(repo, "new.cs")); + client.Verify(x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + private static string CreateGitRepo() + { + var path = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + LibGit2Sharp.Repository.Init(path); + return path; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs new file mode 100644 index 00000000..5a0591c7 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -0,0 +1,636 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class WorkspaceWatchCoordinatorTests + { + [TestMethod] + public async Task Sync_SendsWatchFilesForResolvedRepo() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + } + + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task ApplyInventory_PrunesMonitorAndPreservesDirtyBuffers() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + ISet keep = null; + var dirtyPath = Path.Combine(repo, "dirty.cs"); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = dirtyPath, Content = "x", IsDirty = true }, + }); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback, ISet>((_, paths) => keep = paths); + + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "tracked.cs" } }); + + Assert.IsNotNull(keep); + Assert.IsTrue(keep.Any(path => path.EndsWith("tracked.cs", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(keep.Any(path => path.EndsWith("dirty.cs", StringComparison.OrdinalIgnoreCase))); + } + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task Sync_SubdirectoryScope_SendsRelativeWatchPaths() + { + var repo = CreateGitRepo(); + var nested = Path.Combine(repo, "src"); + Directory.CreateDirectory(nested); + try + { + IReadOnlyList scopes = null; + var client = new Mock(); + client.Setup(x => x.WatchFiles(It.IsAny(), It.IsAny>())) + .Callback>((_, paths) => scopes = paths); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { nested }); + } + + Assert.IsNotNull(scopes); + Assert.IsTrue(scopes.Any(path => path.Replace('\\', '/') == "src")); + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task StopAll_SendsStopWatchFiles() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + coordinator.StopAll(); + } + + client.Verify(x => x.StopWatchFiles(It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + pipeline.Verify(x => x.Reset(), Times.AtLeastOnce); + pipeline.Verify(x => x.SetActiveRepos(It.Is>(roots => roots.Count == 0)), Times.AtLeastOnce); + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task ServerRestart_RestoresWatchesAndDirtyBuffers() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var dirtyPath = Path.Combine(repo, "dirty.cs"); + File.WriteAllText(dirtyPath, "x"); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = dirtyPath, Content = "x", IsDirty = true }, + }); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + client.Invocations.Clear(); + client.Raise(x => x.ServerStarted += null, client.Object, new ServerStartEvent { Restart = true }); + await Task.Delay(50); + } + + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + pipeline.Verify(x => x.SubmitBatchAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task StopAll_IgnoresLaterInventoryForClosedRepo() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var pruneKeeps = new List>(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback, ISet>((_, paths) => pruneKeeps.Add(paths)); + using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + { + await coordinator.SyncAsync(new[] { repo }); + coordinator.StopAll(); + var countAfterStop = pruneKeeps.Count; + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "old.cs" } }); + + Assert.HasCount(countAfterStop, pruneKeeps); + Assert.IsEmpty(pruneKeeps[pruneKeeps.Count - 1]); + } + } + finally + { + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } + } + } + + [TestMethod] + public async Task Sync_RemovesRepositoryDroppedFromWorkspace() + { + var firstRepo = CreateGitRepo(); + var secondRepo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { firstRepo, secondRepo }); + client.Invocations.Clear(); + + await coordinator.SyncAsync(new[] { secondRepo }); + + client.Verify( + x => x.StopWatchFiles( + It.Is(path => path.Equals(firstRepo, StringComparison.OrdinalIgnoreCase)), + It.IsAny>()), + Times.Once); + } + finally + { + Directory.Delete(firstRepo, true); + Directory.Delete(secondRepo, true); + } + } + + [TestMethod] + public async Task Sync_RepeatedScope_SeedsWithoutRewatching() + { + var repo = CreateGitRepo(); + var filePath = Path.Combine(repo, "dirty.cs"); + File.WriteAllText(filePath, "x"); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = filePath, Content = "x", IsDirty = true }, + }); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + + await coordinator.SyncAsync(new[] { repo }); + await coordinator.SyncAsync(new[] { repo }); + + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.Once); + pipeline.Verify(x => x.SubmitBatchAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task GetInventoryFiles_ReturnsCurrentInventory() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + Assert.IsEmpty(coordinator.GetInventoryFiles(repo)); + + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "tracked.cs" } }); + + Assert.Contains("tracked.cs", coordinator.GetInventoryFiles(repo)); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task DeltaReceived_UnknownPath_RefreshesInventory() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + client.Setup(x => x.GetWatchInventoryAsync(repo, It.IsAny())) + .ReturnsAsync(new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs", "new.cs" } }); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); + + await coordinator.AdmitsDeltaAsync(repo, "new.cs"); + + await WaitUntilAsync(() => coordinator.GetInventoryFiles(repo).Contains("new.cs")); + client.Verify(x => x.GetWatchInventoryAsync(repo, It.IsAny()), Times.Once); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task DeltaReceived_TrackedOrDirtyPath_DoesNotRefreshInventory() + { + var repo = CreateGitRepo(); + var dirtyPath = Path.Combine(repo, "dirty.cs"); + File.WriteAllText(dirtyPath, "x"); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = dirtyPath, Content = "x", IsDirty = true }, + }); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); + + Assert.IsTrue(await coordinator.AdmitsDeltaAsync(repo, "known.cs")); + Assert.IsTrue(await coordinator.AdmitsDeltaAsync(repo, "dirty.cs")); + await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 100); + + client.Verify(x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task InvalidNotificationsAndPostDisposeSync_AreIgnored() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Invocations.Clear(); + + client.Raise(x => x.WatchInventoryChanged += null, client.Object, null); + client.Raise(x => x.DeltaReceived += null, client.Object, null); + client.Raise(x => x.ServerStarted += null, client.Object, null); + client.Raise(x => x.ServerStarted += null, client.Object, new ServerStartEvent { Restart = false }); + coordinator.Dispose(); + coordinator.Dispose(); + await coordinator.SyncAsync(new[] { repo }); + + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.Never); + client.Verify(x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task DeltaReceived_BeforeInventory_IsIgnored() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification { RepoRoot = repo, Path = "new.cs", Result = new DeltaResponseModel() }); + await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 100); + + client.Verify( + x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task DeltaReceived_RepeatedUnknownPath_LogsRefreshFailure() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + client.Setup(x => x.GetWatchInventoryAsync(repo, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("inventory failed")); + var pipeline = new Mock(); + var logger = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator( + client.Object, + pipeline.Object, + logger.Object, + host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = Array.Empty() }); + + var first = coordinator.AdmitsDeltaAsync(repo, "new.cs"); + var second = coordinator.AdmitsDeltaAsync(repo, "new.cs"); + await Task.WhenAll(first, second); + + await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 200); + logger.Verify( + x => x.Debug(It.Is(message => message.Contains("inventory refresh skipped"))), + Times.Once); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task StopAll_CancelsPendingInventoryRefresh() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = Array.Empty() }); + var admission = coordinator.AdmitsDeltaAsync(repo, "new.cs"); + coordinator.StopAll(); + await admission; + await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 100); + + client.Verify( + x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public void SetEnabled_WhenStateUnchanged_IsNoOp() + { + var client = new Mock(); + var pipeline = new Mock(); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null); + coordinator.SetEnabled(true); + } + + [TestMethod] + public async Task SetEnabled_StopsWatchesUntilAnalysisStartsAgain() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Invocations.Clear(); + + coordinator.SetEnabled(false); + await coordinator.SyncAsync(new[] { repo }); + + client.Verify(x => x.StopWatchFiles(It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.Never); + + coordinator.SetEnabled(true); + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task AdmitsDeltaAsync_AfterRefreshStillMissing_RejectsDelta() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + client.Setup(x => x.GetWatchInventoryAsync(repo, It.IsAny())) + .ReturnsAsync(new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); + + Assert.IsFalse(await coordinator.AdmitsDeltaAsync(repo, "missing.cs")); + client.Verify(x => x.GetWatchInventoryAsync(repo, It.IsAny()), Times.Once); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task AdmitsDeltaAsync_WhenCoordinatorDisabled_ReturnsTrueWithoutRefresh() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + coordinator.SetEnabled(false); + + Assert.IsTrue(await coordinator.AdmitsDeltaAsync(repo, "unknown.cs")); + client.Verify(x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task QueueIdle_ReprunesMonitor() + { + var repo = CreateGitRepo(); + try + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + var pruneCount = 0; + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) + .Callback(() => pruneCount++); + using var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + await coordinator.SyncAsync(new[] { repo }); + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = repo, Files = new[] { "tracked.cs" } }); + var afterInventory = pruneCount; + + client.Raise(x => x.QueueChanged += null, client.Object, new ReviewQueue { Count = 0, Files = Array.Empty() }); + await Task.Delay(WorkspaceWatchCoordinator.QueueIdlePruneDelayMs + 150); + + Assert.IsGreaterThan(afterInventory, pruneCount); + } + finally + { + Directory.Delete(repo, true); + } + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException("Timed out waiting for workspace inventory refresh."); + } + + await Task.Delay(25); + } + } + + private static string CreateGitRepo() + { + var path = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + LibGit2Sharp.Repository.Init(path); + return path; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchScopeTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchScopeTests.cs new file mode 100644 index 00000000..7a39bcee --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchScopeTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Git; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class WorkspaceWatchScopeTests + { + [TestMethod] + public async Task Sync_ParentAndSubdirectory_WatchesWholeRepo() + { + var repo = CreateGitRepo(); + var nested = Path.Combine(repo, "src"); + Directory.CreateDirectory(nested); + try + { + IReadOnlyList scopes = new[] { "unset" }; + var client = new Mock(); + client.Setup(x => x.WatchFiles(It.IsAny(), It.IsAny>())) + .Callback>((_, paths) => scopes = paths); + using (var coordinator = CreateCoordinator(client)) + { + await coordinator.SyncAsync(new[] { repo, nested }); + } + + Assert.IsNull(scopes); + client.Verify(x => x.WatchFiles(It.IsAny(), It.IsAny>()), Times.Once); + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task Sync_NestedForeignRepo_WatchesBothRepos() + { + var parent = CreateGitRepo(); + var nested = Path.Combine(parent, "vendor", "lib"); + Directory.CreateDirectory(nested); + LibGit2Sharp.Repository.Init(nested); + try + { + var roots = new List(); + var client = new Mock(); + client.Setup(x => x.WatchFiles(It.IsAny(), It.IsAny>())) + .Callback>((root, _) => roots.Add(root)); + using (var coordinator = CreateCoordinator(client)) + { + await coordinator.SyncAsync(new[] { parent, nested }); + } + + Assert.HasCount(2, roots); + Assert.IsTrue(roots.Any(root => root.Equals(parent, StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(roots.Any(root => root.Equals(nested, StringComparison.OrdinalIgnoreCase))); + } + finally + { + Directory.Delete(parent, true); + } + } + + [TestMethod] + public async Task Sync_ScopeChange_StopsBeforeStarting() + { + var repo = CreateGitRepo(); + var src = Path.Combine(repo, "src"); + var lib = Path.Combine(repo, "lib"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(lib); + try + { + var order = new List(); + var client = new Mock(); + client.Setup(x => x.StopWatchFiles(It.IsAny(), It.IsAny>())) + .Callback(() => order.Add("stop")); + client.Setup(x => x.WatchFiles(It.IsAny(), It.IsAny>())) + .Callback(() => order.Add("watch")); + using (var coordinator = CreateCoordinator(client)) + { + await coordinator.SyncAsync(new[] { src }); + order.Clear(); + await coordinator.SyncAsync(new[] { lib }); + Assert.HasCount(2, order); + Assert.AreEqual("stop", order[0]); + Assert.AreEqual("watch", order[1]); + } + } + finally + { + Directory.Delete(repo, true); + } + } + + [TestMethod] + public async Task Sync_SessionCapturedBeforeStop_DoesNotWatchAgain() + { + var repo = CreateGitRepo(); + try + { + var watches = 0; + var client = new Mock(); + client.Setup(x => x.WatchFiles(It.IsAny(), It.IsAny>())) + .Callback(() => watches++); + using (var coordinator = CreateCoordinator(client)) + { + var session = coordinator.WatchSession; + await coordinator.SyncAsync(new[] { repo }, session); + coordinator.StopAll(); + await coordinator.SyncAsync(new[] { repo }, session); + Assert.AreEqual(1, watches); + + await coordinator.SyncAsync(new[] { repo }); + Assert.AreEqual(2, watches); + } + } + finally + { + Directory.Delete(repo, true); + } + } + + private static WorkspaceWatchCoordinator CreateCoordinator(Mock client) + { + var pipeline = new Mock(); + var host = new Mock(); + host.Setup(x => x.GetDirtyDocuments()).Returns(Array.Empty()); + return new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object); + } + + private static string CreateGitRepo() + { + var path = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + LibGit2Sharp.Repository.Init(path); + return path; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Ace/AceManager.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Ace/AceManager.cs index 6389260d..c3ad4463 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Ace/AceManager.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Ace/AceManager.cs @@ -95,6 +95,10 @@ public async Task RefactorAsync(string path, FnToR return cacheItem; } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception e) { _logger.Error($"Error during refactoring of method {refactorableFunction.Name}", e); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/BaselineReviewCacheService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/BaselineReviewCacheService.cs deleted file mode 100644 index c59f9682..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/BaselineReviewCacheService.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; - -namespace Codescene.VSExtension.Core.Application.Cache.Review -{ - public class BaselineReviewCacheService - { - private static readonly ConcurrentDictionary SharedCache = new ConcurrentDictionary(); - private readonly ConcurrentDictionary _cache; - - public BaselineReviewCacheService() - { - _cache = SharedCache; - } - - public BaselineReviewCacheService(ConcurrentDictionary store) - { - _cache = store; - } - - public (bool Found, string RawScore) Get(string filePath, string baselineContent) - { - if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(baselineContent)) - { - return (false, null); - } - - var key = CacheKey(filePath, baselineContent); - if (!_cache.TryGetValue(key, out var entry)) - { - return (false, null); - } - - return (true, entry); - } - - public void Put(string filePath, string baselineContent, string rawScore, long? operationGeneration = null) - { - if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(baselineContent)) - { - return; - } - - var key = CacheKey(filePath, baselineContent); - _cache[key] = rawScore ?? string.Empty; - } - - public void Clear() - { - _cache.Clear(); - } - - public void Invalidate(string filePath) - { - if (string.IsNullOrEmpty(filePath)) - { - return; - } - - var pathToMatch = filePath.ToLowerInvariant(); - foreach (var key in _cache.Keys.ToList()) - { - if (!TryParseCacheKey(key, out var pathFromKey, out _)) - { - continue; - } - - if (pathFromKey.Equals(pathToMatch, StringComparison.Ordinal)) - { - _cache.TryRemove(key, out _); - } - } - } - - public bool RemoveEntriesOutsideRoot(string gitRootPath) - { - if (string.IsNullOrEmpty(gitRootPath)) - { - return false; - } - - var rootPrefix = GetRootPrefix(gitRootPath); - var keysToRemove = GetKeysToRemove(rootPrefix); - - if (keysToRemove.Any()) - { - RemoveKeys(keysToRemove); - return true; - } - - return false; - } - - private static bool TryParseCacheKey(string key, out string path, out string hash) - { - path = null; - hash = null; - var sep = key.IndexOf('|'); - if (sep < 0) - { - return false; - } - - path = key.Substring(0, sep); - hash = key.Substring(sep + 1); - return true; - } - - private static string CacheKey(string filePath, string baselineContent) - { - var hash = Hash(baselineContent); - return filePath.ToLowerInvariant() + "|" + hash; - } - - private static string Hash(string content) - { - using var sha = SHA256.Create(); - var bytes = Encoding.UTF8.GetBytes(content); - var hashBytes = sha.ComputeHash(bytes); - return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant(); - } - - private string GetRootPrefix(string gitRootPath) - { - return Path.GetFullPath(gitRootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - } - - private List GetKeysToRemove(string rootPrefix) - { - var keysToRemove = new List(); - - foreach (var key in _cache.Keys) - { - if (!TryParseCacheKey(key, out var pathFromKey, out _)) - { - continue; - } - - if (ShouldRemoveKey(pathFromKey, rootPrefix)) - { - keysToRemove.Add(key); - } - } - - return keysToRemove; - } - - private bool ShouldRemoveKey(string pathFromKey, string rootPrefix) - { - try - { - var fullPath = Path.GetFullPath(pathFromKey); - return fullPath.Length > 0 && !fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase); - } - catch - { - return true; - } - } - - private void RemoveKeys(List keysToRemove) - { - foreach (var k in keysToRemove) - { - _cache.TryRemove(k, out _); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/DeltaCacheService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/DeltaCacheService.cs index 8ce44d1e..08988ce1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/DeltaCacheService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/DeltaCacheService.cs @@ -108,6 +108,16 @@ public DeltaResponseModel GetDeltaForFile(string filePath) return null; } + public void SetPresentationDelta(string filePath, DeltaResponseModel delta) + { + if (string.IsNullOrEmpty(filePath)) + { + return; + } + + Cache[GetCacheKey(filePath)] = new DeltaCacheItem(filePath, string.Empty, string.Empty, delta, CacheGeneration.Current); + } + public override void Invalidate(string key) { base.Invalidate(GetCacheKey(key)); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/ReviewCacheCleanup.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/ReviewCacheCleanup.cs deleted file mode 100644 index b80ad9b3..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/ReviewCacheCleanup.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Application.Cache.Review -{ - public static class ReviewCacheCleanup - { - public static bool CleanupCaches(string gitRootPath) - { - var deltaCache = new DeltaCacheService(); - var reviewCache = new ReviewCacheService(); - var baselineCache = new BaselineReviewCacheService(); - var didCleanup = false; - didCleanup |= deltaCache.RemoveEntriesOutsideRoot(gitRootPath); - didCleanup |= deltaCache.CleanupOldGenerations(); - - didCleanup |= reviewCache.RemoveEntriesOutsideRoot(gitRootPath); - - didCleanup |= baselineCache.RemoveEntriesOutsideRoot(gitRootPath); - return didCleanup; - } - - public static void InvalidateFile(string filePath) - { - new DeltaCacheService().Invalidate(filePath); - new BaselineReviewCacheService().Invalidate(filePath); - new ReviewCacheService().Invalidate(filePath); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CachingCodeReviewer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CachingCodeReviewer.cs deleted file mode 100644 index 9e820f02..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CachingCodeReviewer.cs +++ /dev/null @@ -1,373 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Models; -using Codescene.VSExtension.Core.Models.Cache.Delta; -using Codescene.VSExtension.Core.Models.Cache.Review; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Util; - -namespace Codescene.VSExtension.Core.Application.Cli -{ - public class CachingCodeReviewer : ICodeReviewer - { - private readonly ICodeReviewer _innerReviewer; - private readonly ReviewCacheService _cache; - private readonly BaselineReviewCacheService _baselineCache; - private readonly DeltaCacheService _deltaCache; - private readonly ILogger _logger; - private readonly IGitService _git; - private readonly ITelemetryManager _telemetryManager; - private readonly ICodeHealthMonitorNotifier _notifier; - private readonly ConcurrentDictionary>> _pendingReviews = new ConcurrentDictionary>>(); - private readonly ConcurrentDictionary>> _pendingDeltas = new ConcurrentDictionary>>(); - - public CachingCodeReviewer( - ICodeReviewer innerReviewer, - ReviewCacheService cache = null, - BaselineReviewCacheService baselineCache = null, - DeltaCacheService deltaCache = null, - ILogger logger = null, - IGitService git = null, - ITelemetryManager telemetryManager = null, - ICodeHealthMonitorNotifier notifier = null) - { - _innerReviewer = innerReviewer; - _cache = cache ?? new ReviewCacheService(); - _baselineCache = baselineCache ?? new BaselineReviewCacheService(); - _deltaCache = deltaCache ?? new DeltaCacheService(); - _logger = logger; - _git = git; - _telemetryManager = telemetryManager; - _notifier = notifier; - } - - public async Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(content) || string.IsNullOrWhiteSpace(path)) - { - return null; - } - - var normalizedPath = path.ToLowerInvariant(); - var query = new ReviewCacheQuery(content, normalizedPath, isBaseline); - var cached = _cache.Get(query); - - if (cached != null) - { - _logger?.Debug($"CachingCodeReviewer: Cache hit for '{path}'."); - return cached; - } - - var pendingKey = GetPendingKey(content, path, isBaseline); - - var lazyTask = _pendingReviews.GetOrAdd(pendingKey, _ => - new Lazy>(() => - ReviewInternalAsync(path, content, isBaseline, operationGeneration, CancellationToken.None))); - - var pendingTask = lazyTask.Value; - - try - { - var result = await pendingTask.ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - return result; - } - finally - { - _pendingReviews.TryRemove(pendingKey, out _); - } - } - - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var review = await this.ReviewAsync(path, currentCode, isBaseline: false, operationGeneration, cancellationToken); - var baselineRawScore = await GetOrComputeBaselineRawScoreAsync(path, null, operationGeneration, cancellationToken, baselineCommit); - - return (review, baselineRawScore ?? string.Empty); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - if (review?.RawScore == null) - { - return (review, null); - } - - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); - } - - public async Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var oldCode = GetBaselineContent(path, baselineContent, baselineCommit); - - if (string.IsNullOrEmpty(oldCode)) - { - return string.Empty; - } - - var baselineEntry = _baselineCache.Get(path, oldCode); - if (baselineEntry.Found) - { - _logger?.Debug($"CachingCodeReviewer: Baseline cache hit for '{path}'."); - return baselineEntry.RawScore ?? string.Empty; - } - - return await ComputeAndCacheBaselineAsync(path, oldCode, operationGeneration, cancellationToken); - } - - public async Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var path = review.FilePath; - - if (string.IsNullOrWhiteSpace(path)) - { - _logger?.Warn("Could not review file, missing file path."); - return null; - } - - var oldCode = _git?.GetFileContentForCommit(path, baselineCommit) ?? string.Empty; - - if (oldCode == currentCode) - { - _logger?.Debug($"Delta analysis skipped for {Path.GetFileName(path)}: content unchanged."); - - // If unchanged file exists in the cache, which could happen if user undos changes, clear and update views. - if (_deltaCache.Contains(path)) - { - _deltaCache.Invalidate(path); - _notifier?.RequestViewUpdate(); - } - - return null; - } - - var cacheQuery = new DeltaCacheQuery(path, oldCode, currentCode); - var cacheEntry = _deltaCache.Get(cacheQuery); - if (cacheEntry.Item1) - { - _logger?.Debug($"CachingCodeReviewer: Delta cache hit for '{path}'."); - return cacheEntry.Item2; - } - - var pendingKey = GetPendingDeltaKey(path, oldCode, currentCode, precomputedBaselineRawScore); - var input = new DeltaComputationInput(currentCode, oldCode, precomputedBaselineRawScore, baselineCommit); - var lazyTask = _pendingDeltas.GetOrAdd(pendingKey, _ => - new Lazy>(() => - ComputeDeltaWithLifecycleAsync(review, input, operationGeneration, CancellationToken.None))); - var pendingTask = lazyTask.Value; - try - { - var delta = await pendingTask.ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - return delta; - } - finally - { - _pendingDeltas.TryRemove(pendingKey, out _); - } - } - - private string GetBaselineContent(string path, string baselineContent, string baselineCommit = null) - { - if (!string.IsNullOrEmpty(baselineContent)) - { - return baselineContent; - } - - if (_git != null) - { - return _git.GetFileContentForCommit(path, baselineCommit) ?? string.Empty; - } - - return string.Empty; - } - - private async Task ComputeAndCacheBaselineAsync(string path, string oldCode, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - _logger?.Debug($"CachingCodeReviewer: Baseline cache miss for '{path}', calling reviewer."); - var oldCodeReview = await this.ReviewAsync(path, oldCode, isBaseline: true, operationGeneration, cancellationToken); - var oldRawScore = oldCodeReview?.RawScore ?? string.Empty; - - if (oldCodeReview?.RawScore != null) - { - _baselineCache.Put(path, oldCode, oldCodeReview.RawScore, operationGeneration); - } - - return oldRawScore; - } - - private async Task ComputeDeltaInternalAsync(FileReviewModel review, DeltaComputationInput input, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - var path = review.FilePath; - var currentRawScore = review.RawScore ?? string.Empty; - - var oldRawScore = input.PrecomputedBaselineRawScore - ?? await GetOrComputeBaselineRawScoreAsync(path, input.OldCode, operationGeneration, cancellationToken, input.BaselineCommit); - - if (oldRawScore == currentRawScore) - { - _logger?.Debug($"Delta analysis skipped for {Path.GetFileName(path)}: scores identical."); - if (_deltaCache.Contains(path)) - { - _deltaCache.Invalidate(path); - _notifier?.RequestViewUpdate(); - } - - return null; - } - - var parameters = new DeltaComputationParameters( - currentCode: input.CurrentCode, - oldCode: input.OldCode, - oldRawScore: oldRawScore, - baselineCommit: input.BaselineCommit); - return await ComputeAndCacheDeltaAsync(review, parameters, operationGeneration, cancellationToken); - } - - private async Task ComputeAndCacheDeltaAsync(FileReviewModel review, DeltaComputationParameters parameters, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - var path = review.FilePath; - _logger?.Debug($"CachingCodeReviewer: Delta cache miss for '{path}', calling inner reviewer."); - var delta = await _innerReviewer.DeltaAsync(review, parameters.CurrentCode, parameters.OldRawScore, operationGeneration, cancellationToken, parameters.BaselineCommit); - cancellationToken.ThrowIfCancellationRequested(); - - var cacheSnapshot = new Dictionary(_deltaCache.GetAll()); - var cacheEntry = new DeltaCacheEntry(path, parameters.OldCode, parameters.CurrentCode, delta); - _deltaCache.Put(cacheEntry, operationGeneration); - - if (_telemetryManager != null) - { - DeltaTelemetryHelper.HandleDeltaTelemetryEvent( - cacheSnapshot, _deltaCache.GetAll(), cacheEntry, _telemetryManager); - } - - return delta; - } - - private async Task ComputeDeltaWithLifecycleAsync( - FileReviewModel review, - DeltaComputationInput input, - long? operationGeneration = null, - CancellationToken cancellationToken = default) - { - var path = review.FilePath; - _notifier?.OnDeltaStarting(path); - try - { - try - { - return await ComputeDeltaInternalAsync(review, input, operationGeneration, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - _logger?.Error($"Could not perform delta analysis on file {path}", e); - return null; - } - } - finally - { - _notifier?.OnDeltaCompleted(path); - } - } - - private async Task ReviewInternalAsync(string path, string content, bool isBaseline, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - _logger?.Debug($"CachingCodeReviewer: Cache miss for '{path}', calling inner reviewer."); - var result = await _innerReviewer.ReviewAsync(path, content, isBaseline, operationGeneration, cancellationToken); - if (result != null) - { - var entry = new ReviewCacheEntry(content, path.ToLowerInvariant(), result, isBaseline); - _cache.Put(entry, operationGeneration); - _logger?.Debug($"CachingCodeReviewer: Cached result for '{path}'."); - } - - return result; - } - - private string GetPendingKey(string content, string path, bool isBaseline) - { - using (var sha = SHA256.Create()) - { - var bytes = Encoding.UTF8.GetBytes(content); - var hashBytes = sha.ComputeHash(bytes); - var hash = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant(); - return path.ToLowerInvariant() + "|" + hash + "|baseline=" + isBaseline; - } - } - - private string GetPendingDeltaKey(string path, string oldCode, string currentCode, string precomputedBaselineRawScore) - { - using (var sha = SHA256.Create()) - { - var oldHash = ComputeHash(sha, oldCode ?? string.Empty); - var currentHash = ComputeHash(sha, currentCode ?? string.Empty); - var baselineHash = ComputeHash(sha, precomputedBaselineRawScore ?? string.Empty); - return path.ToLowerInvariant() + "|delta|" + oldHash + "|" + currentHash + "|" + baselineHash; - } - } - - private string ComputeHash(HashAlgorithm algorithm, string content) - { - var bytes = Encoding.UTF8.GetBytes(content); - var hashBytes = algorithm.ComputeHash(bytes); - return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant(); - } - - private class DeltaComputationInput - { - public DeltaComputationInput(string currentCode, string oldCode, string precomputedBaselineRawScore, string baselineCommit = null) - { - CurrentCode = currentCode; - OldCode = oldCode; - PrecomputedBaselineRawScore = precomputedBaselineRawScore; - BaselineCommit = baselineCommit; - } - - public string CurrentCode { get; } - - public string OldCode { get; } - - public string PrecomputedBaselineRawScore { get; } - - public string BaselineCommit { get; } - } - - private class DeltaComputationParameters - { - public DeltaComputationParameters(string currentCode, string oldCode, string oldRawScore, string baselineCommit = null) - { - CurrentCode = currentCode; - OldCode = oldCode; - OldRawScore = oldRawScore; - BaselineCommit = baselineCommit; - } - - public string CurrentCode { get; } - - public string OldCode { get; } - - public string OldRawScore { get; } - - public string BaselineCommit { get; } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliCommandProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliCommandProvider.cs deleted file mode 100644 index 4e723eb2..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliCommandProvider.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Text; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Refactor; -using Codescene.VSExtension.Core.Models.Cli.Review; -using Newtonsoft.Json; - -namespace Codescene.VSExtension.Core.Application.Cli -{ - [Export(typeof(ICliCommandProvider))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class CliCommandProvider : ICliCommandProvider - { - private static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Include }; - - private static readonly JsonSerializerSettings RefactorPostSerializerSettings = new JsonSerializerSettings - { - DefaultValueHandling = DefaultValueHandling.Include, - NullValueHandling = NullValueHandling.Ignore, - }; - - private readonly ICliObjectScoreCreator _creator; - - [ImportingConstructor] - public CliCommandProvider(ICliObjectScoreCreator creator) - { - _creator = creator; - } - - public string VersionCommand => "version --sha"; - - public string DeviceIdCommand => "telemetry --device-id"; - - public string RefactorCommand => "run-command fns-to-refactor"; - - public string RefactorPostCommand => "run-command refactor"; - - public string ReviewFileContentCommand => "run-command review"; - - public string SendTelemetryCommand(string jsonEvent) => $"telemetry --event \"{AdjustTelemetryQuotes(jsonEvent)}\""; - - public string GetPreflightSupportInformationCommand(bool force) - { - var useForceArg = force ? " --force" : string.Empty; - return $"refactor preflight{useForceArg}"; - } - - public string GetRefactorWithCodeSmellsPayload(string fileName, string fileContent, string cachePath, IList codeSmells, PreFlightResponseModel preflight = null) - { - var request = new FnsToRefactorCodeSmellRequestModel { CodeSmells = codeSmells }; - return SerializeRefactorRequest(request, fileName, fileContent, cachePath, preflight); - } - - public string GetRefactorWithDeltaResultPayload(string fileName, string fileContent, string cachePath, DeltaResponseModel deltaResult, PreFlightResponseModel preflight = null) - { - var request = new FnsToRefactorDeltaRequestModel { DeltaResult = deltaResult }; - return SerializeRefactorRequest(request, fileName, fileContent, cachePath, preflight); - } - - public string GetRefactorPostPayload(FnToRefactorModel fnToRefactor, bool skipCache, string token) - { - if (fnToRefactor == null) - { - throw new ArgumentNullException(nameof(fnToRefactor)); - } - - if (token == null) - { - throw new ArgumentNullException(nameof(token)); - } - - if (token.Length == 0) - { - throw new ArgumentException("Token must not be empty.", nameof(token)); - } - - var request = new RefactorPostRequestModel - { - Token = token, - }; - - if (skipCache) - { - request.SkipCache = true; - } - - if (!string.IsNullOrEmpty(fnToRefactor.NippyB64)) - { - request.FnToRefactorNippyB64 = fnToRefactor.NippyB64; - } - else - { - request.FnToRefactor = fnToRefactor; - } - - return JsonConvert.SerializeObject(request, RefactorPostSerializerSettings); - } - - public string GetReviewFileContentPayload(string filePath, string fileContent, string cachePath) - { - var request = new ReviewRequestModel - { - FilePath = filePath, - FileContent = fileContent, - CachePath = string.IsNullOrWhiteSpace(cachePath) ? null : cachePath, - }; - return JsonConvert.SerializeObject(request, SerializerSettings); - } - - public string GetReviewDeltaCommand(string oldScore, string newScore) - { - var scores = _creator.Create(oldScore, newScore); - return $"{scores}"; - } - - private static string GetArgumentStr(params string[] args) - { - var sb = new StringBuilder(); - foreach (var arg in args) - { - sb.Append(arg).Append(" "); - } - - return sb.ToString(); - } - - private string AdjustTelemetryQuotes(string value) => value.Replace("\"", "\\\""); - - private string SerializeRefactorRequest(FnsToRefactorRequestModel request, string fileName, string fileContent, string cachePath, PreFlightResponseModel preflight) - { - request.FileName = fileName; - request.FileContent = fileContent; - if (!string.IsNullOrWhiteSpace(cachePath)) - { - request.CachePath = cachePath; - } - - request.Preflight = preflight; - return JsonConvert.SerializeObject(request, SerializerSettings); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromCodeSmells.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromCodeSmells.cs new file mode 100644 index 00000000..26268b36 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromCodeSmells.cs @@ -0,0 +1,30 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public partial class CliExecutor + { + public Task> FnsToRefactorFromCodeSmellsAsync(string fileName, string fileContent, IList codeSmells, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) + { + return ExecuteFnsToRefactorAsync( + codeSmells != null && codeSmells.Count > 0, + "Skipping refactoring functions from code smells. Code smells list was null or empty.", + cachePath => new FnsToRefactorCodeSmellRequestModel + { + FileName = fileName, + FileContent = fileContent, + CachePath = cachePath, + Preflight = preflight, + CodeSmells = codeSmells, + }, + "ACE refactoring functions from code smells check", + cancellationToken); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromDelta.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromDelta.cs new file mode 100644 index 00000000..0fdbe814 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromDelta.cs @@ -0,0 +1,31 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public partial class CliExecutor + { + public Task> FnsToRefactorFromDeltaAsync(string fileName, string fileContent, DeltaResponseModel deltaResult, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) + { + return ExecuteFnsToRefactorAsync( + deltaResult != null, + "Skipping refactoring functions from delta. Delta result was null.", + cachePath => new FnsToRefactorDeltaRequestModel + { + FileName = fileName, + FileContent = fileContent, + CachePath = cachePath, + Preflight = preflight, + DeltaResult = deltaResult, + }, + "ACE refactoring functions from delta check", + cancellationToken); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs index 6f0273ad..f0c09d3d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel.Composition; using System.Diagnostics; -using System.IO; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -16,197 +15,47 @@ using Codescene.VSExtension.Core.Interfaces.Cli; using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Codescene.VSExtension.Core.Interfaces.Util; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; -using Newtonsoft.Json; using static Codescene.VSExtension.Core.Consts.Constants; namespace Codescene.VSExtension.Core.Application.Cli { [Export(typeof(ICliExecutor))] [PartCreationPolicy(CreationPolicy.Shared)] - public class CliExecutor : ICliExecutor + public partial class CliExecutor : ICliExecutor { private readonly ILogger _logger; - private readonly ICliServices _cliServices; + private readonly IIdeServerClient _client; + private readonly ICacheStorageService _cacheStorage; private readonly ISettingsProvider _settingsProvider; private readonly Lazy _telemetryManagerLazy; - private readonly ConcurrentDictionary _inFlightReviewCancellation = new ConcurrentDictionary(); private readonly ConcurrentDictionary>>> _pendingRefactorRequests = new ConcurrentDictionary>>>(); - private readonly SemaphoreSlim _cliCommandChannel; - private readonly SemaphoreSlim _deltaChannel = new SemaphoreSlim(1, 1); - private readonly ICpuUsageThrottler _cpuUsageThrottler; [ImportingConstructor] public CliExecutor( ILogger logger, - ICliServices cliServices, + IIdeServerClient client, + ICacheStorageService cacheStorage, ISettingsProvider settingsProvider, - [Import(AllowDefault = true)] Lazy telemetryManagerLazy = null, - [Import(AllowDefault = true)] ICpuUsageThrottler cpuUsageThrottler = null) - : this(logger, cliServices, settingsProvider, telemetryManagerLazy, cpuUsageThrottler, 1) - { - } - - internal CliExecutor( - ILogger logger, - ICliServices cliServices, - ISettingsProvider settingsProvider, - Lazy telemetryManagerLazy, - ICpuUsageThrottler cpuUsageThrottler, - int cliCommandConcurrencyLimit) + [Import(AllowDefault = true)] Lazy telemetryManagerLazy = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _cliServices = cliServices ?? throw new ArgumentNullException(nameof(cliServices)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _cacheStorage = cacheStorage ?? throw new ArgumentNullException(nameof(cacheStorage)); _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider)); _telemetryManagerLazy = telemetryManagerLazy; - _cpuUsageThrottler = cpuUsageThrottler ?? new NoOpCpuUsageThrottler(); - var effectiveLimit = Math.Max(1, cliCommandConcurrencyLimit); - _cliCommandChannel = new SemaphoreSlim(effectiveLimit, effectiveLimit); - } - - /// - /// Reviews a file's content by invoking the CLI with the appropriate arguments. - /// - /// The path of the file being reviewed. - /// The content (code) of the file to be reviewed. - /// True when reviewing baseline (committed) content for delta; used to avoid cancelling in-flight current-content reviews. - /// Cancellation token. - /// A containing the review results, or null if the review fails. - public async Task ReviewContentAsync(string filePath, string content, bool isBaseline = false, CancellationToken cancellationToken = default) - { - var fileName = Path.GetFileName(filePath); - var key = GetReviewCancellationKey(GetReviewCancellationPathIdentity(filePath), isBaseline); - var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - var oldCts = _inFlightReviewCancellation.AddOrUpdate(key, cts, (_, existing) => - { - try - { - existing.Cancel(); - } - catch (ObjectDisposedException) - { - // ignored - } - - existing.Dispose(); - return cts; - }); - - try - { - var command = _cliServices.CommandProvider.ReviewFileContentCommand; - var payload = _cliServices.CommandProvider.GetReviewFileContentPayload(filePath, content, _cliServices.CacheStorage.GetSolutionReviewCacheLocation()); - var workingDirectory = GetCliWorkingDirectoryForFile(filePath); - - var (result, elapsedMs) = await ExecuteOnChannelAsync( - _cliCommandChannel, - cts.Token, - () => ExecuteWithTimingAndLoggingAsync( - "CLI file review", - () => _cliServices.ProcessExecutor.ExecuteAsync(command, payload, null, cts.Token, workingDirectory), - $"Review of file {fileName} failed")); - - if (result != null) - { - _ = Task.Run(async () => - { - var loc = PerformanceTelemetryHelper.CalculateLineCount(content); - var language = PerformanceTelemetryHelper.ExtractLanguage(fileName); - var telemetryData = new PerformanceTelemetryData - { - Type = Titles.REVIEW, - ElapsedMs = elapsedMs, - FilePath = fileName, - Loc = loc, - Language = language, - }; - await PerformanceTelemetryHelper.SendPerformanceTelemetryAsync(GetTelemetryManager(), _logger, telemetryData); - }); - } - - return result; - } - catch (OperationCanceledException) - { - return null; - } - finally - { - if (_inFlightReviewCancellation.TryGetValue(key, out var currentCts) && currentCts == cts) - { - _inFlightReviewCancellation.TryRemove(key, out _); - } - - cts.Dispose(); - } - } - - public async Task ReviewDeltaAsync(ReviewDeltaRequest request, CancellationToken cancellationToken = default) - { - var arguments = _cliServices.CommandProvider.GetReviewDeltaCommand(request.OldScore, request.NewScore); - - if (string.IsNullOrEmpty(arguments)) - { - _logger.Warn("Skipping delta review. Arguments were not defined."); - return null; - } - - await _deltaChannel.WaitAsync(cancellationToken); - try - { - await _cpuUsageThrottler.WaitForCpuAsync(cancellationToken); - var workingDirectory = GetCliWorkingDirectoryForFile(request.FilePath); - var (result, elapsedMs) = await ExecuteWithTimingAndLoggingAsync( - "CLI file delta review", - () => _cliServices.ProcessExecutor.ExecuteAsync(Titles.DELTA, arguments, null, cancellationToken, workingDirectory), - "Delta for file failed."); - - if (result != null && !string.IsNullOrEmpty(request.FilePath)) - { - _ = Task.Run(async () => - { - var loc = PerformanceTelemetryHelper.CalculateLineCount(request.FileContent); - var language = PerformanceTelemetryHelper.ExtractLanguage(request.FilePath); - var telemetryData = new PerformanceTelemetryData - { - Type = Titles.DELTA, - ElapsedMs = elapsedMs, - FilePath = request.FilePath, - Loc = loc, - Language = language, - }; - await PerformanceTelemetryHelper.SendPerformanceTelemetryAsync(GetTelemetryManager(), _logger, telemetryData); - }); - } - - return result; - } - finally - { - _deltaChannel.Release(); - } } public async Task PreflightAsync(bool force = true, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var arguments = _cliServices.CommandProvider.GetPreflightSupportInformationCommand(force: force); - if (string.IsNullOrEmpty(arguments)) - { - _logger.Warn("Skipping preflight. Arguments were not defined."); - return null; - } - - var taskResult = await ExecuteWithTimingAndLoggingAsync( + var taskResult = await ExecuteWithTimingAndLoggingAsync( "ACE preflight", - () => _cliServices.ProcessExecutor.ExecuteAsync(arguments, null, Codescene.VSExtension.Core.Consts.Constants.Timeout.TELEMETRYTIMEOUT, cancellationToken), + () => _client.PreflightAsync(force, cancellationToken), "Preflight failed."); return taskResult.Result; } @@ -226,17 +75,23 @@ public async Task PostRefactoringAsync(FnToRefactorModel throw missingTokenEx; } - var arguments = _cliServices.CommandProvider.RefactorPostCommand; - var payload = _cliServices.CommandProvider.GetRefactorPostPayload(fnToRefactor, skipCache, effectiveToken); - if (string.IsNullOrEmpty(arguments) || string.IsNullOrEmpty(payload)) + var request = new RefactorPostRequestModel { - _logger.Warn("Skipping refactoring. Arguments were not defined."); - return null; + Token = effectiveToken, + SkipCache = skipCache ? true : (bool?)null, + }; + if (!string.IsNullOrEmpty(fnToRefactor?.NippyB64)) + { + request.FnToRefactorNippyB64 = fnToRefactor.NippyB64; + } + else + { + request.FnToRefactor = fnToRefactor; } - var (result, elapsedMs) = await ExecuteWithTimingAndLoggingAsync( + var (result, elapsedMs) = await ExecuteWithTimingAndLoggingAsync( "ACE refactoring", - () => _cliServices.ProcessExecutor.ExecuteAsync(arguments, payload, null, cancellationToken), + () => _client.RefactorAsync(request, cancellationToken), "Refactoring failed."); if (result != null && fnToRefactor != null) @@ -257,65 +112,35 @@ public async Task PostRefactoringAsync(FnToRefactorModel return result; } - public async Task> FnsToRefactorFromCodeSmellsAsync(string fileName, string fileContent, IList codeSmells, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) - { - return await ExecuteFnsToRefactorAsync( - isValid: codeSmells != null && codeSmells.Count > 0, - skipMessage: "Skipping refactoring functions from code smells. Code smells list was null or empty.", - getPayload: cachePath => _cliServices.CommandProvider.GetRefactorWithCodeSmellsPayload(fileName, fileContent, cachePath, codeSmells, preflight), - operationLabel: "ACE refactoring functions from code smells check", - cancellationToken: cancellationToken); - } - - public async Task> FnsToRefactorFromDeltaAsync(string fileName, string fileContent, DeltaResponseModel deltaResult, PreFlightResponseModel preflight, CancellationToken cancellationToken = default) + public Task GetDeviceIdAsync(CancellationToken cancellationToken = default) { - return await ExecuteFnsToRefactorAsync( - isValid: deltaResult != null, - skipMessage: "Skipping refactoring functions from delta. Delta result was null.", - getPayload: cachePath => _cliServices.CommandProvider.GetRefactorWithDeltaResultPayload(fileName, fileContent, cachePath, deltaResult, preflight), - operationLabel: "ACE refactoring functions from delta check", - cancellationToken: cancellationToken); - } - - public async Task GetDeviceIdAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return await ExecuteSimpleCommandAsync( - _cliServices.CommandProvider.DeviceIdCommand, + return TryReadServerValueAsync( + async token => await _client.DeviceIdAsync(token), "Could not get device ID", cancellationToken); } - public async Task GetFileVersionAsync(CancellationToken cancellationToken = default) + public Task GetFileVersionAsync(CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - return await ExecuteSimpleCommandAsync( - _cliServices.CommandProvider.VersionCommand, + return TryReadServerValueAsync( + async token => + { + var metadata = await _client.StartAsync(token); + return metadata?.Sha; + }, "Could not get CLI version", cancellationToken); } - private static string GetReviewCancellationPathIdentity(string filePath) - { - try - { - return string.IsNullOrEmpty(filePath) ? string.Empty : Path.GetFullPath(filePath); - } - catch - { - return filePath ?? string.Empty; - } - } - - private static string GetReviewCancellationKey(string filePathIdentity, bool isBaseline) => - string.IsNullOrEmpty(filePathIdentity) ? string.Empty : filePathIdentity + (isBaseline ? ":baseline" : ":current"); - - private async Task ExecuteSimpleCommandAsync(string command, string errorMessage, CancellationToken cancellationToken = default) + private async Task TryReadServerValueAsync( + Func> read, + string errorMessage, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); try { - var result = await _cliServices.ProcessExecutor.ExecuteAsync(command, null, null, cancellationToken); - return result?.Trim().TrimEnd('\r', '\n'); + return (await read(cancellationToken))?.Trim() ?? string.Empty; } catch (Exception e) { @@ -353,11 +178,11 @@ private void SendPerformanceTelemetry(PerformanceTelemetryData telemetryData) } private async Task> ExecuteFnsToRefactorAsync( - bool isValid, - string skipMessage, - Func getPayload, - string operationLabel, - CancellationToken cancellationToken = default) + bool isValid, + string skipMessage, + Func createRequest, + string operationLabel, + CancellationToken cancellationToken = default) { if (!isValid) { @@ -365,19 +190,13 @@ private async Task> ExecuteFnsToRefactorAsync( return null; } - var cachePath = _cliServices.CacheStorage.GetSolutionReviewCacheLocation(); - var payloadContent = getPayload(cachePath); - if (string.IsNullOrEmpty(payloadContent)) - { - _logger.Warn("Skipping refactoring functions check. Payload content was not defined."); - return null; - } - - var pendingKey = GetPendingRefactorRequestKey(operationLabel, payloadContent); + var cachePath = _cacheStorage.GetSolutionReviewCacheLocation(); + var request = createRequest(cachePath); + var pendingKey = GetPendingRefactorRequestKey(operationLabel, request); var lazyTask = _pendingRefactorRequests.GetOrAdd( pendingKey, __ => new Lazy>>(() => - ExecuteFnsToRefactorCommandAsync(payloadContent, operationLabel, operationLabel + " failed.", CancellationToken.None))); + ExecuteFnsToRefactorCommandAsync(request, operationLabel, operationLabel + " failed.", CancellationToken.None))); var pendingTask = lazyTask.Value; _ = pendingTask.ContinueWith( __ => @@ -393,44 +212,28 @@ private async Task> ExecuteFnsToRefactorAsync( return await AwaitWithCancellationAsync(pendingTask, cancellationToken); } - private async Task> ExecuteFnsToRefactorCommandAsync(string payloadContent, string operationLabel, string errorMessage, CancellationToken cancellationToken = default) + private async Task> ExecuteFnsToRefactorCommandAsync(FnsToRefactorRequestModel request, string operationLabel, string errorMessage, CancellationToken cancellationToken = default) { - _cliServices.CacheStorage.RemoveOldReviewCacheEntries(); - - var command = _cliServices.CommandProvider.RefactorCommand; - - var (result, _) = await ExecuteWithTimingAndLoggingAsync>( + _cacheStorage.RemoveOldReviewCacheEntries(); + var (result, _) = await ExecuteWithTimingAndLoggingAsync( operationLabel, - () => _cliServices.ProcessExecutor.ExecuteAsync(command, payloadContent, null, cancellationToken), + () => _client.FnsToRefactorAsync(request, cancellationToken), errorMessage); return result; } - private string GetPendingRefactorRequestKey(string operationLabel, string payloadContent) + private string GetPendingRefactorRequestKey(string operationLabel, FnsToRefactorRequestModel request) { using (var sha = SHA256.Create()) { - var bytes = Encoding.UTF8.GetBytes(payloadContent ?? string.Empty); + var payload = (request.FileName ?? string.Empty) + "|" + (request.FileContent ?? string.Empty) + "|" + (request.CachePath ?? string.Empty); + var bytes = Encoding.UTF8.GetBytes(payload); var hashBytes = sha.ComputeHash(bytes); var hash = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant(); return operationLabel + "|" + hash; } } - private async Task ExecuteOnChannelAsync(SemaphoreSlim channel, CancellationToken cancellationToken, Func> operation) - { - await channel.WaitAsync(cancellationToken); - try - { - await _cpuUsageThrottler.WaitForCpuAsync(cancellationToken); - return await operation(); - } - finally - { - channel.Release(); - } - } - private async Task AwaitWithCancellationAsync(Task task, CancellationToken cancellationToken) { if (!cancellationToken.CanBeCanceled || task.IsCompleted) @@ -451,7 +254,7 @@ private async Task AwaitWithCancellationAsync(Task task, CancellationTo return await task; } - private async Task<(T Result, long ElapsedMs)> ExecuteWithTimingAndLoggingAsync(string label, Func> execute, string errorMessage) + private async Task<(T Result, long ElapsedMs)> ExecuteWithTimingAndLoggingAsync(string label, Func> execute, string errorMessage) { long elapsedMs = 0; try @@ -462,7 +265,7 @@ private async Task AwaitWithCancellationAsync(Task task, CancellationTo elapsedMs = stopwatch.ElapsedMilliseconds; _logger.Debug($"{Titles.CODESCENE} {label} completed in {elapsedMs} ms."); - return (JsonConvert.DeserializeObject(result), elapsedMs); + return (result, elapsedMs); } catch (DevtoolsException e) { @@ -487,63 +290,5 @@ private async Task AwaitWithCancellationAsync(Task task, CancellationTo return (default, elapsedMs); } } - - private string GetCliWorkingDirectoryForFile(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - return GetCliWorkingDirectoryWithoutFilePath(); - } - - string fullPath; - try - { - fullPath = Path.GetFullPath(filePath); - } - catch - { - fullPath = filePath; - } - - try - { - var discovered = Repository.Discover(fullPath); - if (!string.IsNullOrEmpty(discovered)) - { - using (var repo = new Repository(discovered)) - { - var wd = repo.Info.WorkingDirectory; - if (!string.IsNullOrEmpty(wd)) - { - return PathNormalization.NormalizeWorkingDirectory(wd); - } - } - } - } - catch (Exception ex) - { - _logger.Debug($"Could not resolve git working directory for CLI: {ex.Message}"); - } - - var workspace = _cliServices.CacheStorage.GetWorkspaceDirectory(); - if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) - { - return PathNormalization.NormalizeWorkingDirectory(workspace); - } - - var dir = Path.GetDirectoryName(fullPath); - return string.IsNullOrEmpty(dir) ? null : dir; - } - - private string GetCliWorkingDirectoryWithoutFilePath() - { - var workspace = _cliServices.CacheStorage.GetWorkspaceDirectory(); - if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) - { - return PathNormalization.NormalizeWorkingDirectory(workspace); - } - - return null; - } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliObjectScoreCreator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliObjectScoreCreator.cs deleted file mode 100644 index 3a36cde7..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliObjectScoreCreator.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.ComponentModel.Composition; -using System.Text; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; - -namespace Codescene.VSExtension.Core.Application.Cli -{ - [Export(typeof(ICliObjectScoreCreator))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class CliObjectScoreCreator : ICliObjectScoreCreator - { - private readonly ILogger _logger; - - [ImportingConstructor] - public CliObjectScoreCreator(ILogger logger) - { - _logger = logger; - } - - /// - /// Raw base64 encoded old score. - /// Raw base64 encoded new score. - /// - /// score as json. - public string Create(string oldScore, string newScore) - { - if (string.IsNullOrWhiteSpace(oldScore) && string.IsNullOrWhiteSpace(newScore)) - { - return string.Empty; - } - - // No need to run the delta command if the scores are the same - if (oldScore == newScore) - { - _logger.Debug("Scores are the same, skipping delta..."); - return string.Empty; - } - - var sb = new StringBuilder("{"); - - var oldScoreExists = false; - if (!string.IsNullOrWhiteSpace(oldScore)) - { - sb.Append($"\"old-score\":\"{oldScore}\""); - oldScoreExists = true; - } - - if (!string.IsNullOrWhiteSpace(newScore)) - { - if (oldScoreExists) - { - sb.Append(","); - } - - sb.Append($"\"new-score\":\"{newScore}\""); - } - - sb.Append("}"); - - return sb.ToString(); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliServices.cs deleted file mode 100644 index 286f0ac2..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliServices.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.ComponentModel.Composition; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using Codescene.VSExtension.Core.Interfaces.Cli; - -[assembly: InternalsVisibleTo("Codescene.VSExtension.Core.Tests")] -[assembly: InternalsVisibleTo("Codescene.VSExtension.Core.IntegrationTests")] - -[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1518:File is required to end with a single newline character", Justification = "File ends with proper newline, false positive in pipeline")] - -namespace Codescene.VSExtension.Core.Application.Cli; - -[Export(typeof(ICliServices))] -[PartCreationPolicy(CreationPolicy.Shared)] -[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1009:Closing parenthesis should not be followed by a space", Justification = "Space is required by C# syntax for primary constructors with inheritance")] -[method: ImportingConstructor] -internal class CliServices( - ICliCommandProvider commandProvider, - IProcessExecutor processExecutor, - ICacheStorageService cacheStorage) : ICliServices -{ - private readonly ICliCommandProvider _commandProvider = commandProvider ?? throw new System.ArgumentNullException(nameof(commandProvider)); - private readonly IProcessExecutor _processExecutor = processExecutor ?? throw new System.ArgumentNullException(nameof(processExecutor)); - private readonly ICacheStorageService _cacheStorage = cacheStorage ?? throw new System.ArgumentNullException(nameof(cacheStorage)); - - public ICliCommandProvider CommandProvider => _commandProvider; - - public IProcessExecutor ProcessExecutor => _processExecutor; - - public ICacheStorageService CacheStorage => _cacheStorage; -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs index e8ea831d..5d3f56ca 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs @@ -5,14 +5,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Ace; using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; +using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; namespace Codescene.VSExtension.Core.Application.Cli { @@ -21,152 +21,124 @@ public class CodeReviewer : ICodeReviewer private readonly ILogger _logger; private readonly IModelMapper _mapper; private readonly ICliExecutor _executor; - private readonly ITelemetryManager _telemetryManager; - private readonly IGitService _git; - private readonly ICodeHealthMonitorNotifier _notifier; private readonly IPreflightManager _preflightManager; + private readonly IReviewPipeline _pipeline; + private readonly ISettingsProvider _settings; public CodeReviewer( ILogger logger, IModelMapper mapper, ICliExecutor executor, - ITelemetryManager telemetryManager, - IGitService git, - ICodeHealthMonitorNotifier notifier = null, - IPreflightManager preflightManager = null) + IReviewPipeline pipeline, + IPreflightManager preflightManager = null, + ISettingsProvider settings = null) { _logger = logger; _mapper = mapper; _executor = executor; - _telemetryManager = telemetryManager; - _git = git; - _notifier = notifier; + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); _preflightManager = preflightManager; + _settings = settings; } - public async Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) + public Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, CancellationToken cancellationToken = default) { - var fileName = Path.GetFileName(path); + return ReviewWithPipelineAsync(path, content, cancellationToken); + } - if (string.IsNullOrWhiteSpace(fileName) || string.IsNullOrWhiteSpace(content)) + private async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithPipelineAsync(string path, string content, CancellationToken cancellationToken) + { + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(path); + if (string.IsNullOrEmpty(repoRoot)) { - _logger.Debug($"Skipping review for '{path}'. Missing content or file path."); - return null; + _logger.Debug($"Skipping pipeline review for '{path}'. Repository root was not found."); + return (null, null); } - _logger?.Info($"Reviewing file {path}...", true); - - var review = await _executor.ReviewContentAsync(path, content, isBaseline, cancellationToken); - return _mapper.Map(path, review); - } + cancellationToken.ThrowIfCancellationRequested(); + var document = new ReviewDocument { FilePath = path, Content = content, IsDirty = true }; + var (cliReview, delta) = await _pipeline.SubmitAsync( + repoRoot, + new ReviewSubmission + { + Document = document, + RelPath = RpcPath.RelativePosix(repoRoot, path), + Content = content, + UpdateDiagnosticsPane = true, + UpdateMonitor = ShouldUpdateMonitor(path, content), + }); + + if (!BufferDiffersFromDisk(path, content)) + { + _pipeline.RestoreFromDiskIfBufferOwned(repoRoot, document); + } - public async Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var oldCode = _git.GetFileContentForCommit(path, baselineCommit) ?? string.Empty; - var reviewTask = ReviewAsync(path, currentCode, false, operationGeneration, cancellationToken); - var baselineTask = GetOrComputeBaselineRawScoreInternalAsync(path, oldCode, operationGeneration, cancellationToken); - await Task.WhenAll(reviewTask, baselineTask).ConfigureAwait(false); - var review = await reviewTask; - var baselineRawScore = (await baselineTask) ?? string.Empty; - return (review, baselineRawScore); - } + if (cliReview == null) + { + return (null, null); + } - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); + var review = _mapper.Map(path, cliReview); if (review?.RawScore == null) { return (review, null); } - var delta = await DeltaAsync(review, content, baselineRawScore, operationGeneration, cancellationToken, baselineCommit); - return (review, delta); + var enriched = await EnrichDeltaAsync(path, content, delta, cancellationToken); + return (review, enriched); } - public async Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) + private bool ShouldUpdateMonitor(string path, string content) { - var path = review.FilePath; - var currentRawScore = review.RawScore ?? string.Empty; - - if (string.IsNullOrWhiteSpace(path)) + if (!(_settings?.EnableAutomaticAnalysis ?? true)) { - _logger.Warn("Could not review file, missing file path."); - return null; + return false; } - try - { - var oldCode = _git.GetFileContentForCommit(path, baselineCommit); - var oldRawScore = precomputedBaselineRawScore ?? await GetOrComputeBaselineRawScoreInternalAsync(path, oldCode, operationGeneration, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - - var delta = await _executor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = oldRawScore, NewScore = currentRawScore, FilePath = path, FileContent = currentCode }, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - if (_preflightManager == null) - { - return delta; - } - - var preflight = await _preflightManager.GetPreflightResponseAsync(cancellationToken); - var refactorableFunctions = await _executor.FnsToRefactorFromDeltaAsync(path, currentCode, delta, preflight, cancellationToken); - if (refactorableFunctions is not { Count: > 0 }) - { - return delta; - } - - foreach (var refactorableFunction in refactorableFunctions) - { - var function = delta?.FunctionLevelFindings?.FirstOrDefault(x => x.Function.Name == refactorableFunction.Name); - if (function != null) - { - function.RefactorableFn = refactorableFunction; - } - } + return BufferDiffersFromDisk(path, content); + } - return delta; + private bool BufferDiffersFromDisk(string path, string content) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return true; } - catch (OperationCanceledException) + + try { - throw; + return !string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal); } - catch (Exception e) + catch (Exception) { - _logger.Error($"Could not perform delta analysis on file {path}", e); - return null; + return true; } } - public async Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) + private async Task EnrichDeltaAsync(string path, string currentCode, DeltaResponseModel delta, CancellationToken cancellationToken) { - var oldCode = !string.IsNullOrEmpty(baselineContent) - ? baselineContent - : (_git?.GetFileContentForCommit(path, baselineCommit) ?? string.Empty); - - if (string.IsNullOrEmpty(oldCode)) + if (_preflightManager == null || delta == null) { - return string.Empty; + return delta; } - return await GetOrComputeBaselineRawScoreInternalAsync(path, oldCode, operationGeneration, cancellationToken); - } - - private async Task GetOrComputeBaselineRawScoreInternalAsync(string path, string oldCode, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - var baselineCache = new BaselineReviewCacheService(); - var baselineEntry = baselineCache.Get(path, oldCode); - if (baselineEntry.Found) + var preflight = await _preflightManager.GetPreflightResponseAsync(cancellationToken); + var refactorableFunctions = await _executor.FnsToRefactorFromDeltaAsync(path, currentCode, delta, preflight, cancellationToken); + if (refactorableFunctions is not { Count: > 0 }) { - return baselineEntry.RawScore ?? string.Empty; + return delta; } - var oldCodeReview = await ReviewAsync(path, oldCode, isBaseline: true, operationGeneration, cancellationToken); - var oldRawScore = oldCodeReview?.RawScore ?? string.Empty; - if (oldCodeReview?.RawScore != null) + foreach (var refactorableFunction in refactorableFunctions) { - baselineCache.Put(path, oldCode, oldCodeReview.RawScore, operationGeneration); + var function = delta.FunctionLevelFindings?.FirstOrDefault(x => x.Function.Name == refactorableFunction.Name); + if (function != null) + { + function.RefactorableFn = refactorableFunction; + } } - return oldRawScore; + return delta; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/GitBlobSha.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/GitBlobSha.cs new file mode 100644 index 00000000..1b593926 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/GitBlobSha.cs @@ -0,0 +1,37 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public static class GitBlobSha + { + public static string FromUtf8(string content) + { + var bytes = Encoding.UTF8.GetBytes(content ?? string.Empty); + return FromBytes(bytes); + } + + public static string FromBytes(byte[] bytes) + { + bytes = bytes ?? new byte[0]; + var header = Encoding.ASCII.GetBytes("blob " + bytes.Length + "\0"); + var payload = new byte[header.Length + bytes.Length]; + Buffer.BlockCopy(header, 0, payload, 0, header.Length); + Buffer.BlockCopy(bytes, 0, payload, header.Length, bytes.Length); + using (var sha1 = SHA1.Create()) + { + var hash = sha1.ComputeHash(payload); + var builder = new StringBuilder(hash.Length * 2); + foreach (var value in hash) + { + builder.Append(value.ToString("x2")); + } + + return builder.ToString(); + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs new file mode 100644 index 00000000..a0346c8f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs @@ -0,0 +1,208 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Newtonsoft.Json.Linq; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public sealed partial class IdeServerClient + { + private void HandleStart(JObject metadata) + { + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(metadata ?? new JObject()); + var parsed = new ServerMetadata + { + Sha = (string)normalized["sha"], + Version = (string)normalized["version"], + }; + Metadata = parsed; + TaskCompletionSource pendingStart; + lock (_gate) + { + pendingStart = _pendingStart; + _pendingStart = null; + } + + pendingStart?.TrySetResult(parsed); + var restart = Interlocked.Increment(ref _startCount) > 1; + _logger.Info("[cs-ide] received start " + LogFields.Format( + LogFields.Field("version", parsed.Version), + LogFields.Field("sha", parsed.Sha), + LogFields.Field("restart", restart))); + ServerStarted?.Invoke(this, new ServerStartEvent { Metadata = parsed, Restart = restart }); + } + + private void HandleReview(JObject notification) => + HandleResultNotification(notification, "fileReview", IdeServerLog.ReviewSummary, ReviewReceived); + + private void HandleDelta(JObject notification) => + HandleResultNotification(notification, "deltaReview", IdeServerLog.DeltaSummary, DeltaReceived); + + private void HandleResultNotification( + JObject notification, + string kind, + Func summarize, + EventHandler handler) + where TNotification : ResultNotification, new() + where TResult : class + { + var identity = ParseIdentity(notification, kind); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var resultToken = normalized["result"]; + var result = resultToken == null || resultToken.Type == JTokenType.Null + ? null + : resultToken.ToObject(Serializer); + _logger.Info("[cs-ide] received " + kind + " " + LogFields.Format( + LogFields.Field("id", identity.Item1 ?? "(none)"), + LogFields.Field("path", identity.Item2), + LogFields.Field("repo", identity.Item3)) + " " + summarize(result)); + handler?.Invoke(this, new TNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Result = result, + }); + } + + private void HandleReviewFailure(JObject notification) + { + var identity = ParseIdentity(notification, "reviewFailed"); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + ReviewFailed?.Invoke(this, new ReviewFailedNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Message = (string)normalized["message"], + }); + } + + private void HandleReviewProgress(JObject notification) + { + var progress = ParseReviewProgress(notification); + if (progress == null) + { + return; + } + + _logger.Info("[cs-ide] received reviewProgress " + LogFields.Format( + LogFields.Field("count", progress.Count), + LogFields.Field("done", progress.Done), + LogFields.Field("files", progress.Files))); + QueueChanged?.Invoke(this, progress); + } + + private void HandleWatchInventory(JObject notification) + { + var inventory = ParseWatchInventory(notification, null); + if (string.IsNullOrEmpty(inventory.RepoRoot)) + { + _logger.Info("[cs-ide] ignoring watchInventoryChanged reason=missing-repo"); + return; + } + + _logger.Info("[cs-ide] received watchInventoryChanged " + LogFields.Format( + LogFields.Field("repo", inventory.RepoRoot), + LogFields.Field("count", inventory.Files.Count), + LogFields.Field("files", inventory.Files))); + WatchInventoryChanged?.Invoke(this, inventory); + } + + private Tuple ParseIdentity(JObject notification, string kind) + { + if (notification == null) + { + _logger.Info("[cs-ide] ignoring " + kind + " reason=missing-notification"); + return null; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var path = (string)normalized["path"]; + var repoRoot = (string)normalized["repo-root"]; + if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(repoRoot)) + { + _logger.Info("[cs-ide] ignoring " + kind + " reason=missing-path-or-repo"); + return null; + } + + var idToken = normalized["id"]; + string id = null; + if (idToken != null && idToken.Type != JTokenType.Null) + { + id = (string)idToken; + } + + return Tuple.Create(id, path, repoRoot); + } + + private WatchInventory ParseWatchInventory(JObject notification, string fallbackRepoRoot) + { + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification ?? new JObject()); + var files = normalized["files"] as JArray; + return new WatchInventory + { + RepoRoot = (string)normalized["repo-root"] ?? fallbackRepoRoot, + Files = files == null ? new List() : files.Select(token => (string)token).Where(value => value != null).ToList(), + }; + } + + private ReviewQueue ParseReviewProgress(JObject notification) + { + if (notification == null) + { + return null; + } + + var files = notification["files"] as JArray; + return new ReviewQueue + { + Count = ReadCount(notification["count"]), + Done = ReadCount(notification["done"]), + Files = (files ?? new JArray()) + .Where(token => token != null && token.Type == JTokenType.String) + .Select(token => (string)token) + .ToList(), + }; + } + + private int ReadCount(JToken token) + { + if (token == null) + { + return 0; + } + + if (token.Type == JTokenType.Integer) + { + return token.Value(); + } + + if (token.Type == JTokenType.Float) + { + return token.Value(); + } + + return 0; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs new file mode 100644 index 00000000..d63513c1 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -0,0 +1,581 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Consts; +using Codescene.VSExtension.Core.Exceptions; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using StreamJsonRpc; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + [Export(typeof(IIdeServerClient))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed partial class IdeServerClient : IIdeServerClient + { + private static readonly JsonSerializer Serializer = JsonSerializer.CreateDefault(); + private readonly ICliSettingsProvider _cliSettingsProvider; + private readonly ILogger _logger; + private readonly IIdeServerProcessFactory _processFactory; + private readonly ISettingsProvider _settingsProvider; + private readonly TimeSpan _startupTimeout; + private readonly object _gate = new object(); + private readonly RpcNotifications _notifications; + private Task _startTask; + private Task _notificationChain = Task.CompletedTask; + private TaskCompletionSource _pendingStart; + private JsonRpc _rpc; + private IIdeServerProcess _process; + private int _startCount; + private int _requestSeq; + private bool _disposed; + private bool _integrityVerified; + private string _verifiedCliPath; + + [ImportingConstructor] + public IdeServerClient( + ICliSettingsProvider cliSettingsProvider, + ILogger logger, + [Import(AllowDefault = true)] ISettingsProvider settingsProvider = null) + : this(cliSettingsProvider, logger, new NativeIdeServerProcessFactory(), settingsProvider, Constants.Timeout.SERVERSTARTUPTIMEOUT) + { + } + + internal IdeServerClient( + ICliSettingsProvider cliSettingsProvider, + ILogger logger, + IIdeServerProcessFactory processFactory, + ISettingsProvider settingsProvider = null, + TimeSpan? startupTimeout = null) + { + _cliSettingsProvider = cliSettingsProvider ?? throw new ArgumentNullException(nameof(cliSettingsProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _processFactory = processFactory ?? throw new ArgumentNullException(nameof(processFactory)); + _settingsProvider = settingsProvider; + _startupTimeout = startupTimeout ?? Constants.Timeout.SERVERSTARTUPTIMEOUT; + _notifications = new RpcNotifications(this); + } + + public event EventHandler ReviewReceived; + + public event EventHandler DeltaReceived; + + public event EventHandler ReviewFailed; + + public event EventHandler WatchInventoryChanged; + + public event EventHandler ServerStarted; + + public event EventHandler QueueChanged; + + public event EventHandler ServerError; + + public ServerMetadata Metadata { get; private set; } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var startTask = GetOrCreateStartTask(); + using (cancellationToken.Register(() => { })) + { + return await startTask.ConfigureAwait(false); + } + } + + public async Task RestartAsync(CancellationToken cancellationToken = default) + { + _logger.Info("[cs-ide] restarting"); + StopProcess(new Exception("CodeScene IDE server restarted")); + await StartAsync(cancellationToken).ConfigureAwait(false); + } + + public Task PreflightAsync(bool force = false, CancellationToken cancellationToken = default) + { + return SendRequestAsync( + "cs-ide/preflight", + new PreflightRequestParams { Force = force ? true : (bool?)null }, + cancellationToken); + } + + public Task> FnsToRefactorAsync(FnsToRefactorRequestModel request, CancellationToken cancellationToken = default) + { + return SendRequestAsync>("cs-ide/fns-to-refactor", request, cancellationToken); + } + + public Task RefactorAsync(RefactorPostRequestModel request, CancellationToken cancellationToken = default) + { + return SendRequestAsync("cs-ide/refactor", request, cancellationToken); + } + + public Task TelemetryAsync(object eventPayload, CancellationToken cancellationToken = default) + { + return SendRequestAsync("cs-ide/telemetry", new TelemetryRequestParams { Event = eventPayload }, cancellationToken); + } + + public async Task DeviceIdAsync(CancellationToken cancellationToken = default) + { + var response = await SendRequestAsync("cs-ide/device-id", new object(), cancellationToken).ConfigureAwait(false); + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(response ?? new JObject()); + return ((string)normalized["device-id"] ?? string.Empty).Trim(); + } + + public async Task CodeHealthRulesTemplateAsync(CancellationToken cancellationToken = default) + { + var response = await SendRequestAsync("cs-ide/code-health-rules-template", new object(), cancellationToken).ConfigureAwait(false); + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(response ?? new JObject()); + return (string)normalized["template"] ?? string.Empty; + } + + public Task CheckRulesAsync(string repoRoot, string path, CancellationToken cancellationToken = default) + { + return SendRequestAsync( + "cs-ide/check-rules", + new CheckRulesRequestParams { RepoRoot = repoRoot, Path = path }, + cancellationToken); + } + + public async Task GetWatchInventoryAsync(string repoRoot, CancellationToken cancellationToken = default) + { + var response = await SendRequestAsync( + "cs-ide/getWatchInventory", + new WatchInventoryRequestParams { RepoRoot = repoRoot }, + cancellationToken).ConfigureAwait(false); + return ParseWatchInventory(response, repoRoot); + } + + public void ReviewFiles(string repoRoot, IReadOnlyList files) + { + var payload = new ReviewFilesParams + { + RepoRoot = repoRoot, + Files = (files ?? Array.Empty()).Select(file => new ReviewFileParams + { + Id = file.Id, + RelPath = file.RelPath, + Content = file.Content, + }).ToList(), + }; + var reviewFiles = payload.Files; + _logger.Info("[cs-ide] sending reviewFiles " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("count", reviewFiles.Count), + LogFields.Field("files", reviewFiles.Select(file => file.RelPath).ToArray()), + LogFields.Field("withContent", reviewFiles.Count(file => file.Content != null)), + LogFields.Field("withId", reviewFiles.Count(file => file.Id != null)))); + EnqueueNotification("cs-ide/reviewFiles", payload); + } + + public void WatchFiles(string repoRoot, IReadOnlyList relativePaths = null) + { + var payload = new WatchFilesParams + { + RepoRoot = repoRoot, + RelativePaths = relativePaths?.ToArray(), + }; + _logger.Info("[cs-ide] sending watchFiles " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("relativePaths", relativePaths ?? (IReadOnlyList)new[] { "(whole repository)" }))); + EnqueueNotification("cs-ide/watchFiles", payload); + } + + public void StopWatchFiles(string repoRoot, IReadOnlyList relativePaths = null) + { + if (_rpc == null) + { + _logger.Info("[cs-ide] skip stopWatchFiles " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "not-running"))); + return; + } + + _logger.Info("[cs-ide] sending stopWatchFiles " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("relativePaths", relativePaths ?? (IReadOnlyList)new[] { "(whole repository)" }))); + var payload = new WatchFilesParams + { + RepoRoot = repoRoot, + RelativePaths = relativePaths?.ToArray(), + }; + EnqueueNotification("cs-ide/stopWatchFiles", payload); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + StopProcess(new Exception("CodeScene IDE server stopped")); + } + + private Task GetOrCreateStartTask() + { + lock (_gate) + { + if (_startTask != null) + { + return _startTask; + } + + _startTask = StartCoreAsync(); + return _startTask; + } + } + + private async Task StartCoreAsync() + { + var cliFilePath = _cliSettingsProvider.CliFileFullPath; + if (!File.Exists(cliFilePath)) + { + throw new FileNotFoundException( + $"CodeScene CLI executable not found at {cliFilePath}. " + + "The CLI should be bundled with the extension. " + + "Please reinstall the extension or contact support if this issue persists.", + cliFilePath); + } + + EnsureCliIntegrity(cliFilePath); + + var threads = ServerWorkerThreads.Resolve(_settingsProvider?.ServerWorkerThreads ?? 0); + var arguments = "server --threads " + threads; + var startCompletion = new TaskCompletionSource(); + IIdeServerProcess process = null; + JsonRpc rpc = null; + + try + { + process = StartLoggedProcess(cliFilePath, arguments); + + rpc = CreateRpc(process); + rpc.AddLocalRpcTarget(_notifications); + rpc.Disconnected += (_, e) => + { + if (e.Exception != null) + { + HandleProcessFailure(e.Exception, process); + } + }; + + lock (_gate) + { + _process = process; + _rpc = rpc; + _pendingStart = startCompletion; + } + + using (var timeoutCts = new CancellationTokenSource(_startupTimeout)) + using (timeoutCts.Token.Register(() => + startCompletion.TrySetException(new TimeoutException($"cs-ide server did not send cs-ide/start within {_startupTimeout.TotalMilliseconds}ms")))) + { + rpc.StartListening(); + var metadata = await startCompletion.Task.ConfigureAwait(false); + if (!string.Equals(metadata.Sha, _cliSettingsProvider.RequiredDevToolVersion, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"CodeScene CLI version mismatch. Expected {_cliSettingsProvider.RequiredDevToolVersion}, received {metadata.Sha}."); + } + + return metadata; + } + } + catch (Exception ex) + { + HandleProcessFailure(ex, process); + throw; + } + } + + private IIdeServerProcess StartLoggedProcess(string cliFilePath, string arguments) + { + _logger.Info("[cs-ide] starting " + LogFields.Format( + LogFields.Field("path", cliFilePath), + LogFields.Field("args", arguments))); + var process = _processFactory.Start(cliFilePath, arguments); + _logger.Info("[cs-ide] started " + LogFields.Format( + LogFields.Field("pid", process.Id), + LogFields.Field("path", cliFilePath))); + process.ErrorDataReceived += (_, e) => + { + var trimmed = (e.Data ?? string.Empty).Trim(); + if (trimmed.Length > 0) + { + _logger.Info("[cs-ide] " + trimmed); + } + }; + process.BeginErrorReadLine(); + process.Exited += (_, __) => HandleProcessFailure(new Exception("cs-ide server exited"), process); + return process; + } + + private JsonRpc CreateRpc(IIdeServerProcess process) + { + var formatter = new JsonMessageFormatter(); + formatter.JsonSerializer.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new KebabCaseNamingStrategy(), + }; + var handler = new HeaderDelimitedMessageHandler(process.StandardInput, process.StandardOutput, formatter); + return new JsonRpc(handler); + } + + private async Task SendRequestAsync(string method, object argument, CancellationToken cancellationToken) + { + var id = Interlocked.Increment(ref _requestSeq); + var started = Stopwatch.StartNew(); + var requestSummary = IdeServerLog.SummarizeRequest(method, argument); + _logger.Info(IdeServerLog.Sending(id, method, requestSummary)); + try + { + var result = await InvokeRequestAsync(method, argument, cancellationToken).ConfigureAwait(false); + _logger.Info(IdeServerLog.Received(id, method, started.ElapsedMilliseconds, IdeServerLog.SummarizeResponse(method, result))); + return result; + } + catch (OperationCanceledException) + { + _logger.Debug( + "[cs-ide] request canceled id=" + id + " method=" + method + " durationMs=" + started.ElapsedMilliseconds); + throw; + } + catch (Exception ex) + { + _logger.Error( + "[cs-ide] request failed id=" + id + " method=" + method + " durationMs=" + started.ElapsedMilliseconds + ": " + ex.Message, + ex); + throw; + } + } + + private async Task InvokeRequestAsync(string method, object argument, CancellationToken cancellationToken) + { + await StartAsync(cancellationToken).ConfigureAwait(false); + JsonRpc rpc; + lock (_gate) + { + rpc = _rpc; + } + + if (rpc == null) + { + throw new InvalidOperationException("CodeScene IDE server is not running"); + } + + try + { + var raw = await rpc.InvokeWithParameterObjectAsync(method, ToParameterObject(argument), cancellationToken).ConfigureAwait(false); + if (raw == null || raw.Type == JTokenType.Null) + { + return default; + } + + var normalized = RpcJsonNormalizer.ToKebabCase(raw); + return normalized.ToObject(Serializer); + } + catch (RemoteInvocationException ex) + { + var status = ex.ErrorCode; + var match = Regex.Match(ex.Message ?? string.Empty, @"^Request failed \[(\d{3})\]"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var httpStatus)) + { + status = httpStatus; + } + + throw new DevtoolsException(ex.Message, status, null); + } + } + + private void EnqueueNotification(string method, object argument) + { + lock (_gate) + { + var prior = _notificationChain; + _notificationChain = prior.ContinueWith( + _ => SendNotificationAsync(method, argument), + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default).Unwrap(); + } + } + + private async Task SendNotificationAsync(string method, object argument) + { + try + { + await StartAsync().ConfigureAwait(false); + JsonRpc rpc; + lock (_gate) + { + rpc = _rpc; + } + + if (rpc == null) + { + throw new InvalidOperationException("CodeScene IDE server is not running"); + } + + await rpc.NotifyWithParameterObjectAsync(method, ToParameterObject(argument)).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error("[cs-ide] notification failed method=" + method + ": " + ex.Message, ex); + HandleError(ex); + } + } + + private object ToParameterObject(object argument) + { + if (argument == null) + { + return new Dictionary(); + } + + var token = argument as JToken ?? JToken.FromObject(argument, Serializer); + var normalized = RpcJsonNormalizer.ToKebabCase(token); + var obj = normalized as JObject; + if (obj == null) + { + return argument; + } + + return obj.ToObject>(Serializer); + } + + private void EnsureCliIntegrity(string cliFilePath) + { + lock (_gate) + { + if (_integrityVerified && string.Equals(_verifiedCliPath, cliFilePath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + CliBinaryIntegrityVerifier.Verify(cliFilePath, _cliSettingsProvider.RequiredCliBinarySha256); + _integrityVerified = true; + _verifiedCliPath = cliFilePath; + } + } + + private void HandleProcessFailure(Exception error, IIdeServerProcess process) + { + lock (_gate) + { + if (IsObsoleteProcess(process) || !HasActiveSession()) + { + return; + } + } + + StopProcess(error); + HandleError(error); + } + + private bool IsObsoleteProcess(IIdeServerProcess process) + { + if (process == null || _process == null) + { + return false; + } + + return !ReferenceEquals(process, _process); + } + + private bool HasActiveSession() + { + return _process != null || _rpc != null || _startTask != null; + } + + private void StopProcess(Exception error) + { + IIdeServerProcess process; + JsonRpc rpc; + lock (_gate) + { + process = _process; + rpc = _rpc; + _process = null; + _rpc = null; + _startTask = null; + var pendingStart = _pendingStart; + _pendingStart = null; + pendingStart?.TrySetException(error ?? new Exception("CodeScene IDE server stopped")); + } + + try + { + rpc?.Dispose(); + } + catch + { + } + + try + { + process?.Kill(); + process?.Dispose(); + } + catch + { + } + } + + private void HandleError(Exception error) + { + _logger.Error("cs-ide server error: " + error.Message, error); + ServerError?.Invoke(this, error); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(IdeServerClient)); + } + } + + private sealed class RpcNotifications + { + private readonly IdeServerClient _client; + + public RpcNotifications(IdeServerClient client) + { + _client = client; + } + + [JsonRpcMethod("cs-ide/start", UseSingleObjectParameterDeserialization = true)] + public void OnStart(JObject metadata) => _client.HandleStart(metadata); + + [JsonRpcMethod("cs-ide/fileReview", UseSingleObjectParameterDeserialization = true)] + public void OnFileReview(JObject notification) => _client.HandleReview(notification); + + [JsonRpcMethod("cs-ide/deltaReview", UseSingleObjectParameterDeserialization = true)] + public void OnDeltaReview(JObject notification) => _client.HandleDelta(notification); + + [JsonRpcMethod("cs-ide/reviewFailed", UseSingleObjectParameterDeserialization = true)] + public void OnReviewFailed(JObject notification) => _client.HandleReviewFailure(notification); + + [JsonRpcMethod("cs-ide/reviewProgress", UseSingleObjectParameterDeserialization = true)] + public void OnReviewProgress(JObject notification) => _client.HandleReviewProgress(notification); + + [JsonRpcMethod("cs-ide/watchInventoryChanged", UseSingleObjectParameterDeserialization = true)] + public void OnWatchInventoryChanged(JObject notification) => _client.HandleWatchInventory(notification); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs new file mode 100644 index 00000000..151a27c0 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs @@ -0,0 +1,288 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Models.Cli.Telemetry; +using Codescene.VSExtension.Core.Util; +using Newtonsoft.Json.Linq; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + internal static class IdeServerLog + { + private static readonly Dictionary> RequestSummaries = + new Dictionary> + { + { "cs-ide/preflight", SummarizePreflightRequest }, + { "cs-ide/fns-to-refactor", argument => SummarizeFnsRequest(argument as FnsToRefactorRequestModel) }, + { "cs-ide/refactor", argument => SummarizeRefactorRequest(argument as RefactorPostRequestModel) }, + { "cs-ide/telemetry", argument => LogFields.Format(LogFields.Field("eventName", TelemetryEventName(argument))) }, + { "cs-ide/check-rules", SummarizeCheckRulesRequest }, + { "cs-ide/getWatchInventory", SummarizeInventoryRequest }, + }; + + private static readonly Dictionary> ResponseSummaries = + new Dictionary> + { + { "cs-ide/preflight", SummarizePreflightResponse }, + { "cs-ide/refactor", SummarizeRefactorResponse }, + { "cs-ide/device-id", argument => LogFields.Format(LogFields.Field("ok", true)) }, + { "cs-ide/code-health-rules-template", argument => LogFields.Format(LogFields.Field("ok", true)) }, + { "cs-ide/check-rules", SummarizeCheckRulesResponse }, + { "cs-ide/getWatchInventory", SummarizeInventoryResponse }, + }; + + public static string Sending(int id, string method, string summary) + { + return "[cs-ide] sending id=" + id + " method=" + method + Suffix(summary); + } + + public static string Received(int id, string method, long durationMs, string summary) + { + return "[cs-ide] received id=" + id + " method=" + method + " durationMs=" + durationMs + Suffix(summary); + } + + public static string SummarizeRequest(string method, object argument) + { + return Summarize(RequestSummaries, method, argument); + } + + public static string SummarizeResponse(string method, object result) + { + if (method == "cs-ide/fns-to-refactor") + { + return SummarizeFnsResponse(result); + } + + if (result == null) + { + return string.Empty; + } + + return Summarize(ResponseSummaries, method, result); + } + + public static string ReviewSummary(CliReviewModel result) + { + if (result == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("score", result.Score), + LogFields.Field("fileSmells", result.FileLevelCodeSmells?.Count ?? 0), + LogFields.Field("functionSmells", result.FunctionLevelCodeSmells?.Count ?? 0), + LogFields.Field("hasRulesError", result.CodeHealthRulesError != null), + LogFields.Field("sha", result.GitBlobSha)); + } + + public static string DeltaSummary(DeltaResponseModel result) + { + if (result == null) + { + return LogFields.Format(LogFields.Field("result", "null")); + } + + return LogFields.Format( + LogFields.Field("oldScore", result.OldScore), + LogFields.Field("newScore", result.NewScore), + LogFields.Field("scoreChange", result.ScoreChange), + LogFields.Field("fileFindings", result.FileLevelFindings?.Length ?? 0), + LogFields.Field("functionFindings", result.FunctionLevelFindings?.Length ?? 0)); + } + + private static string Summarize(Dictionary> summaries, string method, object value) + { + if (method != null && summaries.TryGetValue(method, out var summarize)) + { + return summarize(value); + } + + return string.Empty; + } + + private static string SummarizePreflightRequest(object argument) + { + var preflight = argument as PreflightRequestParams; + return LogFields.Format(LogFields.Field("force", preflight?.Force == true)); + } + + private static string SummarizeCheckRulesRequest(object argument) + { + var rules = argument as CheckRulesRequestParams; + if (rules == null) + { + return string.Empty; + } + + return LogFields.Format(LogFields.Field("repoRoot", rules.RepoRoot), LogFields.Field("path", rules.Path)); + } + + private static string SummarizeInventoryRequest(object argument) + { + var inventory = argument as WatchInventoryRequestParams; + if (inventory == null) + { + return string.Empty; + } + + return LogFields.Format(LogFields.Field("repoRoot", inventory.RepoRoot)); + } + + private static string SummarizeFnsResponse(object result) + { + var functions = result as IList; + if (functions == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("count", functions.Count), + LogFields.Field("functions", functions.Select(SummarizeFunction).ToArray())); + } + + private static string SummarizePreflightResponse(object result) + { + var preflight = result as PreFlightResponseModel; + if (preflight == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("version", preflight.Version), + LogFields.Field("fileTypes", preflight.FileTypes), + LogFields.Field("codeSmellCount", preflight.LanguageCommon?.CodeSmells?.Length ?? 0), + LogFields.Field("maxInputLoc", preflight.LanguageCommon?.MaxInputLoc)); + } + + private static string SummarizeRefactorResponse(object result) + { + var refactor = result as RefactorResponseModel; + if (refactor == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("traceId", refactor.TraceId), + LogFields.Field("confidence", refactor.Confidence?.Level), + LogFields.Field("cached", refactor.Metadata?.Cached == true), + LogFields.Field("reasonCount", refactor.Reasons?.Length ?? 0), + LogFields.Field("added", refactor.RefactoringProperties?.AddedCodeSmells), + LogFields.Field("removed", refactor.RefactoringProperties?.RemovedCodeSmells)); + } + + private static string SummarizeCheckRulesResponse(object result) + { + var rules = result as CheckRulesResponse; + if (rules == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("failed", rules.Failed), + LogFields.Field("parsingErrorCount", rules.ParsingErrors?.Count ?? 0)); + } + + private static string SummarizeInventoryResponse(object result) + { + var inventory = result as WatchInventory; + if (inventory == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("count", inventory.Files?.Count ?? 0), + LogFields.Field("files", inventory.Files)); + } + + private static string SummarizeFnsRequest(FnsToRefactorRequestModel request) + { + if (request == null) + { + return string.Empty; + } + + string input = "none"; + int? smellCount = null; + if (request is FnsToRefactorDeltaRequestModel delta && delta.DeltaResult != null) + { + input = "delta-result"; + } + else if (request is FnsToRefactorCodeSmellRequestModel smells) + { + input = "code-smells"; + smellCount = smells.CodeSmells?.Count; + } + + return LogFields.Format( + LogFields.Field("fileName", request.FileName), + LogFields.Field("input", input), + LogFields.Field("smellCount", smellCount), + LogFields.Field("hasCachePath", request.CachePath != null)); + } + + private static string SummarizeRefactorRequest(RefactorPostRequestModel request) + { + if (request == null) + { + return string.Empty; + } + + var format = request.FnToRefactorNippyB64 != null + ? "nippy-b64" + : request.FnToRefactor != null ? "json" : "none"; + return LogFields.Format( + LogFields.Field("format", format), + LogFields.Field("skipCache", request.SkipCache == true)); + } + + private static string TelemetryEventName(object argument) + { + var payload = argument is TelemetryRequestParams wrapped ? wrapped.Event : argument; + if (payload is TelemetryEvent telemetry) + { + return telemetry.EventName; + } + + if (payload is JObject obj) + { + return (string)obj["event-name"] ?? (string)obj["eventName"]; + } + + return null; + } + + private static string SummarizeFunction(FnToRefactorModel function) + { + if (function == null) + { + return "(invalid)"; + } + + var name = string.IsNullOrEmpty(function.Name) ? "(unnamed)" : function.Name; + var range = function.Range == null + ? string.Empty + : "@" + function.Range.StartLine + ":" + function.Range.StartColumn + "-" + function.Range.EndLine + ":" + function.Range.EndColumn; + var categories = function.RefactoringTargets == null + ? string.Empty + : string.Join(", ", function.RefactoringTargets.Select(target => target.Category).Where(category => !string.IsNullOrEmpty(category))); + return name + range + (categories.Length == 0 ? string.Empty : "[" + categories + "]"); + } + + private static string Suffix(string summary) + { + return string.IsNullOrEmpty(summary) ? string.Empty : " " + summary; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcess.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcess.cs new file mode 100644 index 00000000..4f4a93c7 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcess.cs @@ -0,0 +1,113 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using Codescene.VSExtension.Core.Interfaces.Cli; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + internal sealed class NativeIdeServerProcess : IIdeServerProcess + { + private readonly Process _process; + private readonly StringBuilder _stderr = new StringBuilder(); + + public NativeIdeServerProcess(Process process) + { + _process = process ?? throw new ArgumentNullException(nameof(process)); + _process.EnableRaisingEvents = true; + _process.Exited += (_, __) => Exited?.Invoke(this, EventArgs.Empty); + _process.ErrorDataReceived += (_, e) => + { + if (e.Data == null) + { + return; + } + + if (_stderr.Length < 4000) + { + var remaining = 4000 - _stderr.Length; + _stderr.Append(e.Data.Length <= remaining ? e.Data : e.Data.Substring(0, remaining)); + } + + ErrorDataReceived?.Invoke(this, new IdeServerErrorDataEventArgs(e.Data)); + }; + } + + public event EventHandler Exited; + + public event EventHandler ErrorDataReceived; + + public Stream StandardInput => _process.StandardInput.BaseStream; + + public Stream StandardOutput => _process.StandardOutput.BaseStream; + + public Stream StandardError => _process.StandardError.BaseStream; + + public int Id => _process.Id; + + public bool HasExited + { + get + { + try + { + return _process.HasExited; + } + catch + { + return true; + } + } + } + + public string StderrSnapshot => _stderr.ToString(); + + public void BeginErrorReadLine() + { + _process.BeginErrorReadLine(); + } + + public void Kill() + { + if (HasExited) + { + return; + } + + if (Environment.OSVersion.Platform == PlatformID.Win32NT && _process.Id > 0) + { + try + { + var killer = Process.Start(new ProcessStartInfo + { + FileName = "taskkill", + Arguments = "/pid " + _process.Id + " /T /F", + CreateNoWindow = true, + UseShellExecute = false, + }); + killer?.WaitForExit(5000); + return; + } + catch + { + } + } + + try + { + _process.Kill(); + } + catch + { + } + } + + public void Dispose() + { + Kill(); + _process.Dispose(); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcessFactory.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcessFactory.cs new file mode 100644 index 00000000..ed036776 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcessFactory.cs @@ -0,0 +1,41 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Diagnostics; +using System.Text; +using Codescene.VSExtension.Core.Interfaces.Cli; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + internal sealed class NativeIdeServerProcessFactory : IIdeServerProcessFactory + { + public IIdeServerProcess Start(string fileName, string arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName ?? throw new ArgumentNullException(nameof(fileName)), + Arguments = arguments ?? string.Empty, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + + var process = new Process + { + StartInfo = startInfo, + EnableRaisingEvents = true, + }; + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the CodeScene CLI server process."); + } + + return new NativeIdeServerProcess(process); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ProcessExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ProcessExecutor.cs deleted file mode 100644 index 6e3c8105..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ProcessExecutor.cs +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.IO; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Consts; -using Codescene.VSExtension.Core.Exceptions; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Models.Cli; -using Codescene.VSExtension.Core.Util; -using Newtonsoft.Json; - -[assembly: InternalsVisibleTo("Codescene.VSExtension.Core.Tests")] - -namespace Codescene.VSExtension.Core.Application.Cli -{ - [Export(typeof(IProcessExecutor))] - [PartCreationPolicy(CreationPolicy.Shared)] - internal class ProcessExecutor : IProcessExecutor - { - private readonly object _integrityLock = new object(); - private readonly ICliSettingsProvider _cliSettingsProvider; - private readonly ILogger _logger; - private bool _integrityVerified; - private string _verifiedCliPath; - - [ImportingConstructor] - public ProcessExecutor(ICliSettingsProvider cliSettingsProvider, ILogger logger) - { - _cliSettingsProvider = cliSettingsProvider ?? throw new ArgumentNullException(nameof(cliSettingsProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task ExecuteAsync(string arguments, string content = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, string workingDirectory = null) - { - var cliFilePath = _cliSettingsProvider.CliFileFullPath; - - if (!File.Exists(cliFilePath)) - { - throw new FileNotFoundException( - $"CodeScene CLI executable not found at {cliFilePath}. " + - "The CLI should be bundled with the extension. " + - "Please reinstall the extension or contact support if this issue persists.", - cliFilePath); - } - - EnsureCliIntegrity(cliFilePath); - - var actualTimeout = timeout ?? Constants.Timeout.DEFAULTCLITIMEOUT; - using var timeoutCts = new CancellationTokenSource(); - timeoutCts.CancelAfter(actualTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - var processInfo = new ProcessStartInfo - { - FileName = cliFilePath, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardInput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - if (!string.IsNullOrWhiteSpace(workingDirectory) && Directory.Exists(workingDirectory)) - { - processInfo.WorkingDirectory = workingDirectory; - } - - using var process = new Process(); - process.StartInfo = processInfo; - process.EnableRaisingEvents = true; - - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - var outputTcs = new TaskCompletionSource(); - var errorTcs = new TaskCompletionSource(); - var exitTcs = new TaskCompletionSource(); - - AttachOutputHandlers(new AttachOutputHandlersArgs(process, outputBuilder, errorBuilder, outputTcs, errorTcs)); - - process.Exited += (_, _) => exitTcs.TrySetResult(true); - - var logCommand = TextUtils.BuildCommandForLogging(arguments, content); - _logger.Info($"Executing CLI: {logCommand}"); - - process.Start(); - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - WriteInput(process, content); - - var mainTask = Task.WhenAll(exitTcs.Task, outputTcs.Task, errorTcs.Task); - var timeoutTask = Task.Delay(actualTimeout, linkedCts.Token); - - var completedTask = await Task.WhenAny(mainTask, timeoutTask).ConfigureAwait(false); - - if (completedTask == timeoutTask) - { - return CompleteOnTimeoutOrThrow(process, arguments, actualTimeout, cancellationToken); - } - - await mainTask.ConfigureAwait(false); - - process.WaitForExit(); - - return HandleResult(process, outputBuilder, errorBuilder); - } - - private static bool ShouldPrintError(int code) - { - return code == 10 || code == 11; - } - - private static DevtoolsException TryDeserializeDevtoolsException(string outputText) - { - if (string.IsNullOrWhiteSpace(outputText)) - { - return null; - } - - var trimmed = outputText.TrimStart(); - if (trimmed.Length == 0 || trimmed[0] != '{') - { - return null; - } - - try - { - return JsonConvert.DeserializeObject(outputText); - } - catch (JsonException) - { - return null; - } - } - - private static string CompleteOnTimeoutOrThrow( - Process process, - string arguments, - TimeSpan actualTimeout, - CancellationToken cancellationToken) - { - try - { - if (!process.HasExited) - { - process.Kill(); - } - } - catch - { - } - - if (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(cancellationToken); - } - - if (arguments.Contains("telemetry")) - { - return string.Empty; - } - - throw new TimeoutException($"Process execution exceeded the timeout of {actualTimeout.TotalMilliseconds}ms."); - } - - private void EnsureCliIntegrity(string cliFilePath) - { - lock (_integrityLock) - { - if (_integrityVerified && string.Equals(_verifiedCliPath, cliFilePath, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - CliBinaryIntegrityVerifier.Verify(cliFilePath, _cliSettingsProvider.RequiredCliBinarySha256); - _integrityVerified = true; - _verifiedCliPath = cliFilePath; - } - } - - private void AttachOutputHandlers(AttachOutputHandlersArgs handlerArguments) - { - handlerArguments.Process.OutputDataReceived += (_, e) => - { - if (e.Data == null) - { - handlerArguments.OutputTcs.TrySetResult(true); - } - else - { - handlerArguments.Output.AppendLine(e.Data); - } - }; - - handlerArguments.Process.ErrorDataReceived += (_, e) => - { - if (e.Data == null) - { - handlerArguments.ErrorTcs.TrySetResult(true); - } - else - { - handlerArguments.Error.AppendLine(e.Data); - } - }; - } - - private void WriteInput(Process process, string content) - { - if (!string.IsNullOrWhiteSpace(content)) - { - process.StandardInput.Write(content); - process.StandardInput.Close(); - } - } - - private string HandleResult(Process process, StringBuilder output, StringBuilder error) - { - var code = process.ExitCode; - var outputText = output.ToString(); - var errorText = error.ToString(); - - if (code != 0) - { - var devtoolsEx = TryDeserializeDevtoolsException(outputText); - if (devtoolsEx != null) - { - throw devtoolsEx; - } - - if (ShouldPrintError(code)) - { - throw new Exception( - $"Process exited with code {code}. Could not parse CLI error output. Output: {outputText}. Error stream: {errorText}"); - } - - var detail = string.IsNullOrWhiteSpace(errorText) ? outputText : errorText; - if (string.IsNullOrWhiteSpace(detail)) - { - detail = "(no output)"; - } - - throw new Exception($"Process exited with code {code}. Error: {detail}"); - } - - return outputText; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs new file mode 100644 index 00000000..ea109384 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -0,0 +1,617 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public sealed partial class ReviewPipeline + { + private void OnReviewReceived(object sender, ReviewNotification notification) + { + if (notification == null) + { + return; + } + + if (string.IsNullOrEmpty(notification.Id)) + { + if (!AutomaticAnalysisEnabled()) + { + return; + } + + _ = PresentWatchReviewAsync(notification); + return; + } + + lock (_gate) + { + if (!_pendingById.TryGetValue(notification.Id, out var pending) || pending.ReviewDone) + { + return; + } + + pending.ReviewDone = true; + _presentation.ReviewFinished(pending.Document); + if (!IsCurrent(pending, notification.RepoRoot, notification.Path) || + !MatchesHash(notification.Result?.GitBlobSha, pending.ContentHash)) + { + _logger?.Warn("[pipeline] ignoring fileReview " + LogFields.Format( + LogFields.Field("id", notification.Id), + LogFields.Field("path", notification.Path), + LogFields.Field("repo", notification.RepoRoot), + LogFields.Field("pendingPath", pending.RelPath), + LogFields.Field("pendingRepo", pending.RepoRoot), + LogFields.Field("reason", MismatchReason(pending, notification.RepoRoot, notification.Path, notification.Result?.GitBlobSha)))); + IgnorePending(pending); + return; + } + + pending.ReviewResult = notification.Result; + _logger?.Debug("[pipeline] completed review " + LogFields.Format( + LogFields.Field("id", pending.Id), + LogFields.Field("path", pending.RelPath), + LogFields.Field("repo", pending.RepoRoot))); + _presentation.PresentReview(ToPresentedReview(pending, notification.Result)); + TryComplete(pending); + } + } + + private void QueueWatchDelta(DeltaNotification notification) + { + if (AutomaticAnalysisEnabled()) + { + _ = PresentWatchDeltaAsync(notification); + } + } + + private void PresentCurrentDelta(PendingReview pending, DeltaResponseModel result) + { + var presented = ToPresentedDelta(pending, result); + if (AutomaticAnalysisEnabled()) + { + _presentation.PresentDelta(presented); + return; + } + + presented.UpdateMonitor = false; + _presentation.PresentDelta(presented); + } + + private void OnDeltaReceived(object sender, DeltaNotification notification) + { + if (notification == null) + { + return; + } + + if (string.IsNullOrEmpty(notification.Id)) + { + QueueWatchDelta(notification); + return; + } + + lock (_gate) + { + if (!_pendingById.TryGetValue(notification.Id, out var pending) || pending.DeltaDone) + { + return; + } + + pending.DeltaDone = true; + _presentation.DeltaFinished(pending.Document); + var hash = notification.Result?.NewGitBlobSha; + if (IsCurrent(pending, notification.RepoRoot, notification.Path) && MatchesHash(hash, pending.ContentHash)) + { + pending.DeltaResult = notification.Result; + PresentCurrentDelta(pending, notification.Result); + } + else + { + _logger?.Warn("[pipeline] ignoring deltaReview " + LogFields.Format( + LogFields.Field("id", notification.Id), + LogFields.Field("path", notification.Path), + LogFields.Field("repo", notification.RepoRoot), + LogFields.Field("pendingPath", pending.RelPath), + LogFields.Field("pendingRepo", pending.RepoRoot), + LogFields.Field("reason", MismatchReason(pending, notification.RepoRoot, notification.Path, hash)))); + } + + TryComplete(pending); + } + } + + private void OnReviewFailed(object sender, ReviewFailedNotification failure) + { + if (failure == null) + { + return; + } + + if (string.IsNullOrEmpty(failure.Id)) + { + _logger?.Warn($"[pipeline] watch reviewFailed path={failure.Path} repo={failure.RepoRoot}: {failure.Message}"); + return; + } + + lock (_gate) + { + if (!_pendingById.TryGetValue(failure.Id, out var pending)) + { + return; + } + + if (!IsCurrent(pending, failure.RepoRoot, failure.Path)) + { + IgnorePending(pending); + return; + } + + var error = new Exception(failure.Message); + CompletePending(pending, error); + _latestByPath.Remove(pending.PathKey); + _presentation.Failed(error); + } + } + + private void OnServerError(object sender, Exception error) + { + FailAll(error ?? new Exception("CodeScene IDE server error")); + } + + private async Task PresentWatchReviewAsync(ReviewNotification notification) + { + int epoch; + lock (_gate) + { + if (!AcceptsWatchRepoUnlocked(notification.RepoRoot)) + { + return; + } + + epoch = _dedupEpoch; + } + + var presented = await WatchPresentationAsync(notification.RepoRoot, notification.Path, notification.Result?.GitBlobSha).ConfigureAwait(false); + if (presented == null) + { + return; + } + + lock (_gate) + { + if (!AcceptsWatchRepoUnlocked(notification.RepoRoot) || epoch != _dedupEpoch) + { + return; + } + + CacheWatchReview(PathKey(notification.RepoRoot, presented.RelPath), presented.Content, notification.Result); + _presentation.PresentReview(new PresentedReview + { + Document = presented.Document, + RelPath = presented.RelPath, + Content = presented.Content, + UpdateDiagnosticsPane = presented.UpdateDiagnosticsPane, + UpdateMonitor = presented.UpdateMonitor, + Result = notification.Result, + }); + } + } + + private async Task PresentWatchDeltaAsync(DeltaNotification notification) + { + if (notification.Result != null && !await _watchDeltaFilter(notification.RepoRoot, notification.Path).ConfigureAwait(false)) + { + _logger?.Debug("[pipeline] ignoring watch delta " + LogFields.Format( + LogFields.Field("path", RpcPath.ToPosixRelPath(notification.Path)), + LogFields.Field("repo", notification.RepoRoot), + LogFields.Field("reason", "outside-watch-inventory"))); + return; + } + + int epoch; + lock (_gate) + { + if (!AcceptsWatchRepoUnlocked(notification.RepoRoot)) + { + return; + } + + epoch = _dedupEpoch; + } + + var presented = await WatchPresentationAsync(notification.RepoRoot, notification.Path, notification.Result?.NewGitBlobSha).ConfigureAwait(false); + if (presented == null) + { + return; + } + + lock (_gate) + { + if (!AcceptsWatchRepoUnlocked(notification.RepoRoot) || epoch != _dedupEpoch) + { + return; + } + + _presentation.PresentDelta(new PresentedDelta + { + Document = presented.Document, + RelPath = presented.RelPath, + Content = presented.Content, + UpdateDiagnosticsPane = presented.UpdateDiagnosticsPane, + UpdateMonitor = presented.UpdateMonitor, + Result = notification.Result, + }); + } + } + + private void CacheWatchReview(string pathKey, string content, CliReviewModel result) + { + if (content == null) + { + return; + } + + RemoveWatchReview(pathKey); + _watchReviews[pathKey] = new WatchReviewEntry + { + ContentHash = GitBlobSha.FromUtf8(content), + DedupEpoch = _dedupEpoch, + Result = result, + }; + _watchReviewOrder.Add(pathKey); + if (_watchReviews.Count > MaxWatchReviews) + { + RemoveWatchReview(_watchReviewOrder[0]); + } + } + + private void RemoveWatchReview(string pathKey) + { + if (_watchReviews.Remove(pathKey)) + { + _watchReviewOrder.Remove(pathKey); + } + } + + private async Task WatchPresentationAsync(string repoRoot, string relPath, string receivedSha) + { + var posixPath = RpcPath.ToPosixRelPath(relPath); + var filePath = RpcPath.CombineRepo(RpcPath.ToLocalRepoRoot(repoRoot), posixPath); + var document = _fileAccess.FindOpenDocument(filePath); + if (document == null) + { + try + { + document = await _fileAccess.OpenDocumentAsync(filePath).ConfigureAwait(false); + } + catch + { + document = null; + } + } + + if (document == null) + { + _logger?.Debug("[pipeline] watchPresentation skipped " + LogFields.Format( + LogFields.Field("path", posixPath), + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "no-document"))); + return null; + } + + var (expectedSha, fromBuffer) = await CurrentShaAsync(document, filePath).ConfigureAwait(false); + if (receivedSha != null && receivedSha != expectedSha) + { + _logger?.Warn("[pipeline] ignoring watch result " + LogFields.Format( + LogFields.Field("path", posixPath), + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "stale-sha"), + LogFields.Field("receivedSha", receivedSha), + LogFields.Field("expectedSha", expectedSha))); + if (fromBuffer) + { + ClaimBufferOwnership(repoRoot, posixPath); + } + + return null; + } + + return new ReviewSubmission + { + Document = document, + RelPath = posixPath, + Content = document.Content, + UpdateDiagnosticsPane = _fileAccess.IsVisible(filePath), + UpdateMonitor = true, + }; + } + + private async Task<(string Sha, bool FromBuffer)> CurrentShaAsync(ReviewDocument document, string filePath) + { + var bytes = await _fileAccess.ReadFileBytesAsync(filePath).ConfigureAwait(false); + if (BufferDiffersFromDisk(document, bytes)) + { + return (GitBlobSha.FromUtf8(document.Content), true); + } + + return (GitBlobSha.FromBytes(bytes), false); + } + + private void ClaimBufferOwnership(string repoRoot, string relPath) + { + lock (_gate) + { + _bufferOwnedPaths.Add(PathKey(repoRoot, relPath)); + } + } + + private bool AcceptsWatchRepoUnlocked(string repoRoot) + { + if (_activeRepos == null) + { + return true; + } + + return _activeRepos.Contains(RpcPath.NormalizeFsPath(repoRoot)); + } + + private void FailAll(Exception error) + { + List pendingReviews; + lock (_gate) + { + pendingReviews = _pendingById.Values.ToList(); + foreach (var pending in pendingReviews) + { + CompletePending(pending, error); + } + + _latestByPath.Clear(); + } + + if (pendingReviews.Count > 0) + { + _presentation.Failed(error); + } + } + + private void TryComplete(PendingReview pending) + { + if (pending.ReviewDone && pending.DeltaDone) + { + pending.Completion.TrySetResult((pending.ReviewResult, pending.DeltaResult)); + _pendingById.Remove(pending.Id); + } + } + + private void CompletePending(PendingReview pending, Exception error = null) + { + if (!pending.ReviewDone) + { + pending.ReviewDone = true; + _presentation.ReviewFinished(pending.Document); + } + + if (!pending.DeltaDone) + { + pending.DeltaDone = true; + _presentation.DeltaFinished(pending.Document); + } + + if (error != null) + { + pending.Completion.TrySetException(error); + } + else + { + pending.Completion.TrySetResult((pending.ReviewResult, pending.DeltaResult)); + } + + _pendingById.Remove(pending.Id); + } + + private void IgnorePending(PendingReview pending) + { + CompletePending(pending); + } + + private bool IsCurrent(PendingReview pending, string repoRoot, string relPath) + { + return MismatchReason(pending, repoRoot, relPath) == null; + } + + private string MismatchReason(PendingReview pending, string repoRoot, string relPath, string receivedSha) + { + var reason = MismatchReason(pending, repoRoot, relPath); + if (reason != null) + { + return reason; + } + + return MatchesHash(receivedSha, pending.ContentHash) ? null : "sha-mismatch"; + } + + private string MismatchReason(PendingReview pending, string repoRoot, string relPath) + { + if (!RpcPath.PathsEqual(pending.RepoRoot, repoRoot)) + { + return "repo-mismatch"; + } + + if (RpcPath.ToPosixRelPath(pending.RelPath) != RpcPath.ToPosixRelPath(relPath)) + { + return "path-mismatch"; + } + + if (!_latestByPath.TryGetValue(pending.PathKey, out var latest) || !ReferenceEquals(latest, pending)) + { + return "superseded"; + } + + if (GitBlobSha.FromUtf8(pending.Document.Content) != pending.ContentHash) + { + return "content-changed"; + } + + if (_tombstones.TryGetValue(pending.PathKey, out var tombstone) && tombstone >= pending.Generation) + { + return "tombstoned"; + } + + return null; + } + + private sealed class PendingReview + { + public ReviewDocument Document { get; set; } + + public string RelPath { get; set; } + + public string Content { get; set; } + + public bool UpdateDiagnosticsPane { get; set; } + + public bool UpdateMonitor { get; set; } + + public string Id { get; set; } + + public string RepoRoot { get; set; } + + public string DedupKey { get; set; } + + public string PathKey { get; set; } + + public string ContentHash { get; set; } + + public int Generation { get; set; } + + public bool ReviewDone { get; set; } + + public bool DeltaDone { get; set; } + + public bool Submitted { get; set; } + + public CliReviewModel ReviewResult { get; set; } + + public DeltaResponseModel DeltaResult { get; set; } + + public TaskCompletionSource<(CliReviewModel, DeltaResponseModel)> Completion { get; set; } + } + + private sealed class SubmissionContext + { + public string RepoRoot { get; set; } + + public string PathKey { get; set; } + + public string ContentHash { get; set; } + + public string DedupKey { get; set; } + } + + private sealed class WatchReviewEntry + { + public string ContentHash { get; set; } + + public int DedupEpoch { get; set; } + + public CliReviewModel Result { get; set; } + } + + private sealed class NoOpPresentation : IReviewPipelinePresentation + { + public static readonly NoOpPresentation Instance = new NoOpPresentation(); + + public void ReviewStarted(ReviewDocument document) + { + } + + public void ReviewFinished(ReviewDocument document) + { + } + + public void DeltaStarted(ReviewDocument document) + { + } + + public void DeltaFinished(ReviewDocument document) + { + } + + public void PresentReview(PresentedReview review) + { + } + + public void PresentDelta(PresentedDelta delta) + { + } + + public void Remove(ReviewDocument document) + { + } + + public void Failed(Exception error) + { + } + } + + private sealed class DiskFileAccess : IReviewPipelineFileAccess + { + public static readonly DiskFileAccess Instance = new DiskFileAccess(); + + public ReviewDocument FindOpenDocument(string filePath) + { + return null; + } + + public Task OpenDocumentAsync(string filePath) + { + try + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new ReviewDocument + { + FilePath = filePath, + Content = File.ReadAllText(filePath), + IsDirty = false, + }); + } + catch + { + return Task.FromResult(null); + } + } + + public Task ReadFileBytesAsync(string filePath) + { + try + { + return Task.FromResult(File.Exists(filePath) ? File.ReadAllBytes(filePath) : null); + } + catch + { + return Task.FromResult(null); + } + } + + public bool IsVisible(string filePath) + { + return false; + } + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs new file mode 100644 index 00000000..958ae0ed --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -0,0 +1,585 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + [Export(typeof(IReviewPipeline))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed partial class ReviewPipeline : IReviewPipeline, IDisposable + { + private const int MaxWatchReviews = 250; + private readonly IIdeServerClient _client; + private readonly IReviewPipelinePresentation _presentation; + private readonly IReviewPipelineFileAccess _fileAccess; + private readonly Func _createId; + private readonly ILogger _logger; + private readonly ISettingsProvider _settings; + private readonly object _gate = new object(); + private readonly HashSet _bufferOwnedPaths = new HashSet(StringComparer.Ordinal); + private readonly Dictionary _pendingById = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _latestByPath = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _tombstones = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _generations = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _watchReviews = new Dictionary(StringComparer.Ordinal); + private readonly List _watchReviewOrder = new List(); + private int _dedupEpoch; + private bool _bufferReviewsSuspended; + private HashSet _activeRepos; + private bool _disposed; + private Func> _watchDeltaFilter = (_, __) => Task.FromResult(true); + + [ImportingConstructor] + public ReviewPipeline( + IIdeServerClient client, + [Import(AllowDefault = true)] IReviewPipelinePresentation presentation = null, + [Import(AllowDefault = true)] IReviewPipelineFileAccess fileAccess = null, + [Import(AllowDefault = true)] ILogger logger = null, + [Import(AllowDefault = true)] ISettingsProvider settings = null) + : this(client, presentation, fileAccess, null, logger, settings) + { + } + + internal ReviewPipeline( + IIdeServerClient client, + IReviewPipelinePresentation presentation, + IReviewPipelineFileAccess fileAccess, + Func createId, + ILogger logger, + ISettingsProvider settings = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _presentation = presentation ?? NoOpPresentation.Instance; + _fileAccess = fileAccess ?? DiskFileAccess.Instance; + _createId = createId ?? (() => Guid.NewGuid().ToString("N")); + _logger = logger; + _settings = settings; + _client.ReviewReceived += OnReviewReceived; + _client.DeltaReceived += OnDeltaReceived; + _client.ReviewFailed += OnReviewFailed; + _client.ServerError += OnServerError; + } + + public async Task<(CliReviewModel Review, DeltaResponseModel Delta)> SubmitAsync(string repoRoot, ReviewSubmission submission) + { + var results = await SubmitBatchInternalAsync(repoRoot, new[] { submission }).ConfigureAwait(false); + return results[0]; + } + + public Task SubmitBatchAsync(string repoRoot, ReviewSubmission[] submissions) + { + return SubmitBatchInternalAsync(repoRoot, submissions ?? Array.Empty()); + } + + public void Remove(string repoRoot, params ReviewDocument[] documents) + { + if (documents == null) + { + return; + } + + lock (_gate) + { + foreach (var document in documents) + { + if (document == null) + { + continue; + } + + var pathKey = FindPathKey(repoRoot, document); + if (_latestByPath.TryGetValue(pathKey, out var latest)) + { + IgnorePending(latest); + } + + var generation = NextGeneration(pathKey); + _tombstones[pathKey] = generation; + _latestByPath.Remove(pathKey); + RemoveWatchReview(pathKey); + _bufferOwnedPaths.Remove(pathKey); + _presentation.Remove(document); + } + } + } + + public void ReleaseBufferMonitorOwnership(string repoRoot, ReviewDocument document) + { + if (document == null) + { + return; + } + + lock (_gate) + { + _bufferOwnedPaths.Remove(PathKeyForDocument(repoRoot, document)); + } + } + + public void RestoreFromDiskIfBufferOwned(string repoRoot, ReviewDocument document) + { + if (document == null || string.IsNullOrEmpty(document.FilePath)) + { + return; + } + + var owned = false; + lock (_gate) + { + if (_bufferReviewsSuspended) + { + return; + } + + owned = _bufferOwnedPaths.Contains(PathKeyForDocument(repoRoot, document)); + } + + if (!owned) + { + return; + } + + Remove(repoRoot, document); + _ = SubmitAsync(repoRoot, new ReviewSubmission + { + Document = document, + RelPath = RpcPath.RelativePosix(repoRoot, document.FilePath), + UpdateDiagnosticsPane = false, + UpdateMonitor = true, + }); + } + + public void SetWatchDeltaFilter(Func> filter) + { + _watchDeltaFilter = filter ?? ((_, __) => Task.FromResult(true)); + } + + public void Invalidate() + { + lock (_gate) + { + _dedupEpoch++; + _logger?.Debug("[pipeline] epochInvalidated dedupEpoch=" + _dedupEpoch); + } + } + + public void Reset() + { + lock (_gate) + { + foreach (var pending in _pendingById.Values.ToList()) + { + CompletePending(pending); + } + + _dedupEpoch++; + _latestByPath.Clear(); + _tombstones.Clear(); + _watchReviews.Clear(); + _watchReviewOrder.Clear(); + _bufferOwnedPaths.Clear(); + } + } + + public void SetActiveRepos(IReadOnlyCollection repoRoots) + { + lock (_gate) + { + _activeRepos = repoRoots == null + ? null + : new HashSet(repoRoots.Select(RpcPath.NormalizeFsPath), StringComparer.Ordinal); + } + } + + public void SuspendBufferReviews() + { + lock (_gate) + { + _bufferReviewsSuspended = true; + } + } + + public void ResumeBufferReviews() + { + lock (_gate) + { + _bufferReviewsSuspended = false; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _client.ReviewReceived -= OnReviewReceived; + _client.DeltaReceived -= OnDeltaReceived; + _client.ReviewFailed -= OnReviewFailed; + _client.ServerError -= OnServerError; + FailAll(new Exception("Review pipeline stopped")); + lock (_gate) + { + _latestByPath.Clear(); + _tombstones.Clear(); + _watchReviews.Clear(); + _watchReviewOrder.Clear(); + _bufferOwnedPaths.Clear(); + } + } + + private static bool MatchesHash(string received, string expected) + { + return received == null || received == expected; + } + + private static bool BufferDiffersFromDisk(ReviewDocument document, byte[] diskBytes) + { + if (diskBytes == null) + { + return true; + } + + if (!document.IsDirty) + { + return false; + } + + using (var reader = new StreamReader(new MemoryStream(diskBytes), Encoding.UTF8, true)) + { + return !string.Equals(reader.ReadToEnd(), document.Content ?? string.Empty, StringComparison.Ordinal); + } + } + + private static string PathKey(string repoRoot, string relPath) + { + return RpcPath.CanonicalRepoRoot(repoRoot) + "\0" + RpcPath.ToPosixRelPath(relPath); + } + + private static string PathKeyForDocument(string repoRoot, ReviewDocument document) + { + return PathKey(repoRoot, RpcPath.RelativePosix(repoRoot, document.FilePath)); + } + + private static PresentedReview ToPresentedReview(PendingReview pending, CliReviewModel result) + { + var presented = CopySubmission(pending); + presented.Result = result; + return presented; + } + + private static PresentedDelta ToPresentedDelta(PendingReview pending, DeltaResponseModel result) + { + var presented = CopySubmission(pending); + presented.Result = result; + return presented; + } + + private static T CopySubmission(PendingReview pending) + where T : ReviewSubmission, new() + { + return new T + { + Document = pending.Document, + RelPath = pending.RelPath, + Content = pending.Content, + UpdateDiagnosticsPane = pending.UpdateDiagnosticsPane, + UpdateMonitor = pending.UpdateMonitor, + }; + } + + private bool AutomaticAnalysisEnabled() + { + return _settings?.EnableAutomaticAnalysis ?? true; + } + + private Task<(CliReviewModel Review, DeltaResponseModel Delta)[]> SubmitBatchInternalAsync(string repoRoot, IReadOnlyList submissions) + { + RpcPath.RememberLocalRepoRoot(repoRoot); + var newReviews = new List(); + var diskFiles = new List(); + var tasks = new Task<(CliReviewModel, DeltaResponseModel)>[submissions.Count]; + + lock (_gate) + { + for (var i = 0; i < submissions.Count; i++) + { + var submission = submissions[i]; + if (TryQueueWithoutBufferReview(repoRoot, submission, diskFiles, out var immediate)) + { + tasks[i] = immediate; + continue; + } + + var pending = PrepareSubmission(repoRoot, submission); + if (!pending.Submitted) + { + _pendingById[pending.Id] = pending; + pending.Submitted = true; + newReviews.Add(pending); + } + + tasks[i] = pending.Completion.Task; + } + } + + var files = newReviews + .Select(pending => new ReviewFile { Id = pending.Id, RelPath = pending.RelPath, Content = pending.Content }) + .Concat(diskFiles) + .ToList(); + if (files.Count > 0) + { + _logger?.Debug("[pipeline] submitBatch " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("count", files.Count), + LogFields.Field("buffer", newReviews.Count), + LogFields.Field("disk", diskFiles.Count))); + _client.ReviewFiles(repoRoot, files); + } + + return Task.WhenAll(tasks); + } + + private bool TryQueueWithoutBufferReview( + string repoRoot, + ReviewSubmission submission, + List diskFiles, + out Task<(CliReviewModel, DeltaResponseModel)> task) + { + if (submission.Content != null && !_bufferReviewsSuspended) + { + task = null; + return false; + } + + if (submission.Content == null) + { + var relPath = RpcPath.ToPosixRelPath(submission.RelPath); + diskFiles.Add(new ReviewFile { RelPath = relPath }); + _logger?.Debug("[pipeline] submit " + LogFields.Format( + LogFields.Field("path", relPath), + LogFields.Field("repo", repoRoot), + LogFields.Field("source", "disk"), + LogFields.Field("updateMonitor", submission.UpdateMonitor), + LogFields.Field("updateDiagnostics", submission.UpdateDiagnosticsPane))); + } + + task = Task.FromResult<(CliReviewModel, DeltaResponseModel)>((null, null)); + return true; + } + + private PendingReview PrepareSubmission(string repoRoot, ReviewSubmission submission) + { + if (submission.Document == null) + { + throw new InvalidOperationException("Buffer reviews require a document"); + } + + var normalized = new ReviewSubmission + { + Document = submission.Document, + RelPath = RpcPath.ToPosixRelPath(submission.RelPath), + Content = submission.Content, + UpdateDiagnosticsPane = submission.UpdateDiagnosticsPane, + UpdateMonitor = submission.UpdateMonitor, + }; + var pathKey = PathKey(repoRoot, normalized.RelPath); + var contentHash = GitBlobSha.FromUtf8(normalized.Content); + var context = new SubmissionContext + { + RepoRoot = repoRoot, + PathKey = pathKey, + ContentHash = contentHash, + DedupKey = pathKey + "\0" + contentHash + "\0" + _dedupEpoch, + }; + + _latestByPath.TryGetValue(pathKey, out var latest); + if (latest != null && latest.DedupKey == context.DedupKey) + { + _logger?.Debug("[pipeline] submit reused pending " + LogFields.Format( + LogFields.Field("id", latest.Id), + LogFields.Field("path", normalized.RelPath), + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "dedup"))); + MergePresentation(latest, normalized); + RememberBufferOwnership(pathKey, latest.UpdateMonitor); + return latest; + } + + var reused = ReusedWatchReview(context, normalized, latest); + if (reused != null) + { + return reused; + } + + if (latest != null) + { + IgnorePending(latest); + } + + var pending = CreatePending(context, normalized); + _logger?.Debug("[pipeline] submit " + LogFields.Format( + LogFields.Field("id", pending.Id), + LogFields.Field("path", pending.RelPath), + LogFields.Field("repo", repoRoot), + LogFields.Field("source", "buffer"), + LogFields.Field("updateMonitor", pending.UpdateMonitor), + LogFields.Field("updateDiagnostics", pending.UpdateDiagnosticsPane))); + _tombstones.Remove(pathKey); + _latestByPath[pathKey] = pending; + RememberBufferOwnership(pathKey, pending.UpdateMonitor); + _presentation.ReviewStarted(normalized.Document); + _presentation.DeltaStarted(normalized.Document); + return pending; + } + + private PendingReview CreatePending(SubmissionContext context, ReviewSubmission submission) + { + return new PendingReview + { + Document = submission.Document, + RelPath = submission.RelPath, + Content = submission.Content, + UpdateDiagnosticsPane = submission.UpdateDiagnosticsPane, + UpdateMonitor = submission.UpdateMonitor, + Id = _createId(), + RepoRoot = RpcPath.NormalizeFsPath(context.RepoRoot), + DedupKey = context.DedupKey, + PathKey = context.PathKey, + ContentHash = context.ContentHash, + Generation = NextGeneration(context.PathKey), + Completion = new TaskCompletionSource<(CliReviewModel, DeltaResponseModel)>(TaskCreationOptions.RunContinuationsAsynchronously), + }; + } + + private PendingReview ReusedWatchReview(SubmissionContext context, ReviewSubmission submission, PendingReview latest) + { + if (latest != null) + { + return null; + } + + if (submission.UpdateMonitor) + { + return null; + } + + var cached = CachedWatchReview(context); + if (cached == null) + { + return null; + } + + var pending = CreatePending(context, submission); + pending.Submitted = true; + pending.ReviewDone = true; + pending.DeltaDone = true; + pending.ReviewResult = cached; + pending.Completion.TrySetResult((cached, null)); + if (submission.UpdateDiagnosticsPane) + { + _presentation.PresentReview(ToPresentedReview(pending, cached)); + } + + return pending; + } + + private CliReviewModel CachedWatchReview(SubmissionContext context) + { + if (!_watchReviews.TryGetValue(context.PathKey, out var cached)) + { + return null; + } + + if (cached.ContentHash != context.ContentHash || cached.DedupEpoch != _dedupEpoch) + { + return null; + } + + return cached.Result; + } + + private void MergePresentation(PendingReview pending, ReviewSubmission submission) + { + var replayReview = ShouldReplay(pending.UpdateDiagnosticsPane, submission.UpdateDiagnosticsPane); + var replayDelta = ShouldReplay(pending.UpdateMonitor, submission.UpdateMonitor); + pending.UpdateDiagnosticsPane |= submission.UpdateDiagnosticsPane; + pending.UpdateMonitor |= submission.UpdateMonitor; + ReplayReview(pending, replayReview); + ReplayDelta(pending, replayDelta); + } + + private bool ShouldReplay(bool alreadyEnabled, bool requested) + { + return !alreadyEnabled && requested; + } + + private void ReplayReview(PendingReview pending, bool replay) + { + if (replay && pending.ReviewResult != null) + { + _presentation.PresentReview(ToPresentedReview(pending, pending.ReviewResult)); + } + } + + private void ReplayDelta(PendingReview pending, bool replay) + { + if (replay && pending.DeltaDone) + { + PresentOwnedDelta(pending); + } + } + + private void PresentOwnedDelta(PendingReview pending) + { + if (AutomaticAnalysisEnabled()) + { + _presentation.PresentDelta(ToPresentedDelta(pending, pending.DeltaResult)); + } + } + + private void RememberBufferOwnership(string pathKey, bool updateMonitor) + { + if (updateMonitor) + { + _bufferOwnedPaths.Add(pathKey); + } + } + + private string FindPathKey(string repoRoot, ReviewDocument document) + { + foreach (var pair in _latestByPath) + { + if (RpcPath.PathsEqual(pair.Value.Document.FilePath, document.FilePath)) + { + return pair.Key; + } + } + + return PathKey(repoRoot, RpcPath.RelativePosix(repoRoot, document.FilePath)); + } + + private int NextGeneration(string pathKey) + { + _generations.TryGetValue(pathKey, out var generation); + generation++; + _generations[pathKey] = generation; + return generation; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/RpcJsonNormalizer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/RpcJsonNormalizer.cs new file mode 100644 index 00000000..72889ee8 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/RpcJsonNormalizer.cs @@ -0,0 +1,72 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public static class RpcJsonNormalizer + { + public static JToken ToKebabCase(JToken token) + { + if (token is JObject obj) + { + var result = new JObject(); + foreach (var property in obj.Properties()) + { + result[ToKebabCaseName(property.Name)] = ToKebabCase(property.Value); + } + + return result; + } + + if (token is JArray array) + { + return new JArray(array.Select(ToKebabCase)); + } + + return token; + } + + public static string ToKebabCaseName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + if (string.Equals(name, "nippyB64", System.StringComparison.Ordinal) || + string.Equals(name, "nippy-b-64", System.StringComparison.Ordinal)) + { + return "nippy-b64"; + } + + if (name.IndexOf('-') >= 0) + { + return name; + } + + var builder = new StringBuilder(name.Length + 8); + for (var i = 0; i < name.Length; i++) + { + var current = name[i]; + if (char.IsUpper(current)) + { + if (i > 0) + { + builder.Append('-'); + } + + builder.Append(char.ToLowerInvariant(current)); + } + else + { + builder.Append(current); + } + } + + return builder.ToString(); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ServerWorkerThreads.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ServerWorkerThreads.cs new file mode 100644 index 00000000..3f73a8aa --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ServerWorkerThreads.cs @@ -0,0 +1,19 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; + +namespace Codescene.VSExtension.Core.Application.Cli +{ + public static class ServerWorkerThreads + { + public static int Resolve(int configured) + { + if (configured > 0) + { + return configured; + } + + return Math.Max(1, Environment.ProcessorCount / 2); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/CodesceneRepoConfig.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/CodesceneRepoConfig.cs deleted file mode 100644 index fbc71e07..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/CodesceneRepoConfig.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.IO; -using Newtonsoft.Json; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public static class CodesceneRepoConfig - { - public static string GetBaselineBranch(string gitRootPath) - { - if (string.IsNullOrEmpty(gitRootPath)) - { - return null; - } - - var configPath = Path.Combine( - gitRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - CodesceneFileWatcher.CodesceneDir, - CodesceneFileWatcher.ConfigFileName); - - if (!File.Exists(configPath)) - { - return null; - } - - try - { - var json = File.ReadAllText(configPath); - var model = JsonConvert.DeserializeObject(json); - var branch = model?.BaselineBranch?.Trim(); - return string.IsNullOrEmpty(branch) ? null : branch; - } - catch - { - return null; - } - } - - private sealed class CodesceneConfigModel - { - [JsonProperty("baseline_branch")] - public string BaselineBranch { get; set; } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/DefaultBranchGate.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/DefaultBranchGate.cs deleted file mode 100644 index 5ebe64e5..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/DefaultBranchGate.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class DefaultBranchGate - { - private readonly string _gitRootPath; - private readonly object _cacheLock = new object(); - private string _cachedDefaultBranch; - private bool _hasFetched; - - public DefaultBranchGate(string gitRootPath) - { - _gitRootPath = gitRootPath; - } - - public bool ShouldSkip(Repository repo) - { - if (repo == null) - { - return false; - } - - var currentBranch = repo.Head?.FriendlyName; - if (string.IsNullOrEmpty(currentBranch)) - { - return false; - } - - var defaultBranch = GetDefaultBranchCached(repo); - if (string.IsNullOrEmpty(defaultBranch)) - { - return false; - } - - return string.Equals(currentBranch, defaultBranch, StringComparison.OrdinalIgnoreCase); - } - - public bool ShouldSkipForCurrentBranch() - { - if (string.IsNullOrEmpty(_gitRootPath)) - { - return false; - } - - try - { - var repoPath = Repository.Discover(_gitRootPath); - if (string.IsNullOrEmpty(repoPath)) - { - return false; - } - - using (var repo = new Repository(repoPath)) - { - return ShouldSkip(repo); - } - } - catch (Exception) - { - return false; - } - } - - public void InvalidateCache() - { - lock (_cacheLock) - { - _cachedDefaultBranch = null; - _hasFetched = false; - } - } - - private string GetDefaultBranchCached(Repository repo) - { - lock (_cacheLock) - { - if (!_hasFetched) - { - _cachedDefaultBranch = MainBranchNames.GetDefaultBranch(repo); - _hasFetched = true; - } - - return _cachedDefaultBranch; - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEvent.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEvent.cs deleted file mode 100644 index b342f6a3..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEvent.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Codescene.VSExtension.Core.Enums.Git; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class FileChangeEvent - { - public FileChangeEvent(FileChangeType type, string filePath) - { - Type = type; - FilePath = filePath; - } - - public FileChangeType Type { get; } - - public string FilePath { get; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEventProcessor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEventProcessor.cs deleted file mode 100644 index 6e68dba1..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEventProcessor.cs +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Enums.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Util; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class FileChangeEventProcessor : IDisposable - { - private readonly ConcurrentQueue _eventQueue = new ConcurrentQueue(); - private readonly SemaphoreSlim _concurrencySemaphore; - private readonly ILogger _logger; - private readonly Func, long?, CancellationToken, string, Task> _processEventCallback; - private readonly Func>> _getChangedFilesCallback; - private readonly Func _shouldSkipNonDeletesCallback; - private readonly Func _getBaselineCommitCallback; - private readonly IAsyncTaskScheduler _taskScheduler; - private Timer _scheduledTimer; - private CancellationToken _cancellationToken; - private int _isProcessing; - private int _wasDelayed; - private string _cachedBaselineCommit; - - public FileChangeEventProcessor( - ILogger logger, - IAsyncTaskScheduler taskScheduler, - Func, long?, CancellationToken, string, Task> processEventCallback, - Func>> getChangedFilesCallback, - Func shouldSkipNonDeletesCallback = null, - Func getBaselineCommitCallback = null) - { - _logger = logger; - _taskScheduler = taskScheduler; - _processEventCallback = processEventCallback; - _getChangedFilesCallback = getChangedFilesCallback; - _shouldSkipNonDeletesCallback = shouldSkipNonDeletesCallback; - _getBaselineCommitCallback = getBaselineCommitCallback; - - var numberOfThreads = CoreCountUtils.GetParallelizationCountByCoreCount(Environment.ProcessorCount); - _concurrencySemaphore = new SemaphoreSlim(numberOfThreads, numberOfThreads); - } - - public ConcurrentQueue EventQueue => _eventQueue; - - public Timer ScheduledTimer => _scheduledTimer; - - public void EnqueueEvent(FileChangeEvent evt) - { - _eventQueue.Enqueue(evt); - } - - public void Start(TimeSpan interval, CancellationToken cancellationToken) - { - _cancellationToken = cancellationToken; - _scheduledTimer = new Timer(ProcessQueuedEventsCallback, null, interval, interval); - } - - public void DrainAndStop() - { - _scheduledTimer?.Dispose(); - _scheduledTimer = null; - while (_eventQueue.TryDequeue(out _)) - { - } - } - - public void Dispose() - { - _scheduledTimer?.Dispose(); - _scheduledTimer = null; - _concurrencySemaphore?.Dispose(); - } - - private static bool HasDeleteEvents(List events) - { - foreach (var evt in events) - { - if (evt.Type == FileChangeType.Delete) - { - return true; - } - } - - return false; - } - - private static List CoalesceByPath(List events) - { - var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - var hadDelete = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var evt in events) - { - var path = evt.FilePath; - if (evt.Type == FileChangeType.Delete) - { - hadDelete.Add(path); - } - - byPath[path] = evt.Type == FileChangeType.Delete - ? FileChangeType.Delete - : FileChangeType.Change; - } - - var result = new List(byPath.Count); - foreach (var kv in byPath) - { - var type = hadDelete.Contains(kv.Key) ? FileChangeType.Delete : kv.Value; - result.Add(new FileChangeEvent(type, kv.Key)); - } - - return result; - } - - private void ProcessQueuedEventsCallback(object state) - { - if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) != 0) - { - Interlocked.Exchange(ref _wasDelayed, 1); - return; - } - - var useCache = Interlocked.Exchange(ref _wasDelayed, 0) == 1; - - _taskScheduler.Schedule(async () => - { - try - { - await ProcessQueuedEventsAsync(useCache); - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error processing queued events", ex); - } - finally - { - Interlocked.Exchange(ref _isProcessing, 0); - } - }); - } - - private async Task ProcessQueuedEventsAsync(bool useCache = false) - { - if (_cancellationToken.IsCancellationRequested) - { - return; - } - - var operationGeneration = CacheGeneration.Current; - var events = DrainQueue(); - - if (events.Count == 0) - { - return; - } - - var coalesced = CoalesceByPath(events); - LogCoalescedEvents(coalesced.Count, events.Count); - - if (_cancellationToken.IsCancellationRequested) - { - return; - } - - var shouldSkipNonDeletes = _shouldSkipNonDeletesCallback?.Invoke() ?? false; - - if (ShouldSkipAllEvents(shouldSkipNonDeletes, coalesced)) - { - return; - } - - var baselineCommit = GetOrCacheBaselineCommit(useCache); - var changedFiles = await _getChangedFilesCallback(baselineCommit); - - if (_cancellationToken.IsCancellationRequested) - { - return; - } - - ScheduleCoalescedEvents(coalesced, changedFiles, operationGeneration, shouldSkipNonDeletes, baselineCommit); - } - - private string GetOrCacheBaselineCommit(bool useCache) - { - if (useCache && _cachedBaselineCommit != null) - { - return _cachedBaselineCommit; - } - - var baselineCommit = _getBaselineCommitCallback?.Invoke() ?? string.Empty; - _cachedBaselineCommit = baselineCommit; - return baselineCommit; - } - - private bool ShouldSkipAllEvents(bool shouldSkipNonDeletes, List coalesced) - { - if (shouldSkipNonDeletes && !HasDeleteEvents(coalesced)) - { - _logger?.Debug("FileChangeEventProcessor: Skipping all events - current branch matches default branch and no deletes pending"); - return true; - } - - return false; - } - - private void LogCoalescedEvents(int coalescedCount, int rawCount) - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Processing {coalescedCount} coalesced file change events (from {rawCount} raw)"); -#endif - } - - private List DrainQueue() - { - var events = new List(); - while (_eventQueue.TryDequeue(out var evt)) - { - events.Add(evt); - } - - return events; - } - - private void ScheduleCoalescedEvents(List coalesced, List changedFiles, long? operationGeneration, bool shouldSkipNonDeletes, string baselineCommit) - { - var token = _cancellationToken; - foreach (var evt in coalesced) - { - if (shouldSkipNonDeletes && evt.Type != FileChangeType.Delete) - { - _logger?.Debug($"FileChangeEventProcessor: Skipping {evt.Type} event for {evt.FilePath} - current branch matches default branch"); - continue; - } - - var capturedEvt = evt; - var capturedChangedFiles = changedFiles; - _taskScheduler.Schedule(() => ProcessOneEventAsync(capturedEvt, capturedChangedFiles, operationGeneration, token, baselineCommit)); - } - } - - private async Task ProcessOneEventAsync(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken token, string baselineCommit) - { - if (token.IsCancellationRequested) - { - return; - } - - await _concurrencySemaphore.WaitAsync(token); - try - { - await _processEventCallback(evt, changedFiles, operationGeneration, token, baselineCommit); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error processing file change event", ex); - } - finally - { - _concurrencySemaphore.Release(); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeHandler.cs deleted file mode 100644 index 6f4ac4ec..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeHandler.cs +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Util; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class FileChangeHandler - { - private readonly ILogger _logger; - private readonly ICodeReviewer _codeReviewer; - private readonly ISupportedFileChecker _supportedFileChecker; - private readonly TrackerManager _trackerManager; - private readonly string _gitRootPath; - private readonly Action _onFileDeletedCallback; - private readonly IGitService _gitService; - private readonly IOpenDocumentContentProvider _openDocumentContentProvider; - private IReadOnlyCollection _workspacePaths; - - public FileChangeHandler( - ILogger logger, - ICodeReviewer codeReviewer, - ISupportedFileChecker supportedFileChecker, - IReadOnlyCollection workspacePaths, - TrackerManager trackerManager, - IGitService gitService, - string gitRootPath = null, - Action onFileDeletedCallback = null, - IOpenDocumentContentProvider openDocumentContentProvider = null) - { - _logger = logger; - _codeReviewer = codeReviewer; - _supportedFileChecker = supportedFileChecker; - _workspacePaths = workspacePaths ?? Array.Empty(); - _trackerManager = trackerManager; - _gitService = gitService ?? throw new ArgumentNullException(nameof(gitService)); - _gitRootPath = gitRootPath; - _onFileDeletedCallback = onFileDeletedCallback; - _openDocumentContentProvider = openDocumentContentProvider; - } - - public event EventHandler FileDeletedFromGit; - - public void SetWorkspacePaths(IReadOnlyCollection workspacePaths) - { - _workspacePaths = workspacePaths ?? Array.Empty(); - } - - public async Task HandleFileChangeAsync(string filePath, List changedFiles, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var isDirectory = !Path.HasExtension(filePath); - if (isDirectory) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Skipping directory: {filePath}"); - #endif - return; - } - - if (!ShouldProcessFile(filePath, changedFiles)) - { - if (_trackerManager.Contains(filePath)) - { - _trackerManager.Remove(filePath); - FireFileDeletedFromGit(filePath); - } - - return; - } - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Processing file change: {filePath}"); - #endif - _trackerManager.Add(filePath); - - await ReviewFileAsync(filePath, operationGeneration, cancellationToken, baselineCommit); - } - - public async Task HandleFileDeleteAsync(string filePath, List changedFiles, CancellationToken cancellationToken = default) - { - await Task.Run( - () => - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Processing file delete: {filePath}"); - #endif - var wasTracked = _trackerManager.Contains(filePath); - if (wasTracked) - { - _trackerManager.Remove(filePath); - FireFileDeletedFromGit(filePath); - return; - } - - if (ShouldProcessFile(filePath, changedFiles)) - { - FireFileDeletedFromGit(filePath); - return; - } - - var isDirectory = !Path.HasExtension(filePath); - if (isDirectory) - { - var directoryPrefix = filePath.EndsWith(Path.DirectorySeparatorChar.ToString()) - ? filePath - : filePath + Path.DirectorySeparatorChar; - - var filesToDelete = _trackerManager.GetFilesStartingWith(directoryPrefix); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Handling directory delete '{filePath}' - removing {filesToDelete.Count} files"); - #endif - _trackerManager.RemoveAll(filesToDelete); - - foreach (var fileToDelete in filesToDelete) - { - FireFileDeletedFromGit(fileToDelete); - } - } - }, - cancellationToken); - } - - public bool ShouldProcessFile(string filePath, List changedFiles) - { - if (_gitService.IsFileIgnored(filePath)) - { - return false; - } - - if (!_supportedFileChecker.IsSupported(filePath)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: File not processed - unsupported file type: {filePath}"); - #endif - return false; - } - - if (!IsFileInChangedList(filePath, changedFiles)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: File not processed - not in changed list: {filePath}. Changed list ({changedFiles?.Count ?? 0} files): {string.Join(", ", changedFiles ?? new List())}"); - #endif - return false; - } - - return true; - } - - public async Task ReviewFileAsync(string filePath, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - try - { - cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(filePath)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: File does not exist for review: {filePath}"); - #endif - return; - } - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Starting review for file: {filePath}"); - #endif - string content = null; - if (_openDocumentContentProvider != null) - { - try - { - content = await _openDocumentContentProvider.GetContentForReviewAsync(filePath, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger?.Warn($"GitChangeObserver: Open document provider failed for {filePath}. {ex.Message}"); - } - } - - content ??= File.ReadAllText(filePath); - - cancellationToken.ThrowIfCancellationRequested(); - var (review, delta) = await _codeReviewer.ReviewWithDeltaAsync(filePath, content, operationGeneration, cancellationToken, baselineCommit).ConfigureAwait(false); - - if (review != null) - { - _logger?.Debug($"GitChangeObserver: File reviewed: {filePath}"); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Completed review for file: {filePath}"); - #endif - } - else - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Review returned null for file: {filePath}"); - #endif - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger?.Error($"GitChangeObserver: Could not load file for review {filePath}", ex); - } - } - - private bool IsFileInChangedList(string filePath, List changedFiles) - { - if (_workspacePaths == null || _workspacePaths.Count == 0) - { - return true; - } - - if (!IsInAnyWorkspace(filePath)) - { - return false; - } - - var pathToMatch = string.IsNullOrEmpty(_gitRootPath) - ? filePath - : PathUtilities.GetRelativePath(_gitRootPath, filePath); - var normalizedPathToMatch = pathToMatch.Replace('\\', '/'); - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> FileChangeHandler: Checking if file is in changed list - path: '{normalizedPathToMatch}'"); - #endif - - if (changedFiles == null) - { - return true; - } - - return changedFiles.Any(cf => cf.Replace('\\', '/').Equals(normalizedPathToMatch, StringComparison.OrdinalIgnoreCase)); - } - - private bool IsInAnyWorkspace(string filePath) - { - return GitPathHelper.IsPathUnderAnyRoot(filePath, _workspacePaths); - } - - private void FireFileDeletedFromGit(string filePath) - { - try - { - _logger?.Debug($"GitChangeObserver: File deleted from git: {filePath}"); - - FileDeletedFromGit?.Invoke(this, filePath); - _onFileDeletedCallback?.Invoke(filePath); - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error firing file deleted event", ex); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FirstParentChangeCollector.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FirstParentChangeCollector.cs deleted file mode 100644 index d5cd2f20..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FirstParentChangeCollector.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - /// - /// Collects the paths changed by the commits on the first-parent path between a base commit and HEAD. - /// Merge commits contribute no paths of their own, so merging an advanced baseline into the current - /// branch does not bring the whole baseline into the change set. - /// - public static class FirstParentChangeCollector - { - public static IReadOnlyCollection CollectChangedPaths(Repository repo, Commit baseCommit) - { - var paths = new HashSet(StringComparer.Ordinal); - - if (repo?.Head?.Tip == null || baseCommit == null) - { - return paths; - } - - foreach (var commit in QueryFirstParentCommits(repo, baseCommit)) - { - if (commit.Parents.Count() > 1) - { - continue; - } - - AddChangedPaths(repo, commit, paths); - } - - return paths; - } - - private static IEnumerable QueryFirstParentCommits(Repository repo, Commit baseCommit) - { - return repo.Commits.QueryBy(new CommitFilter - { - IncludeReachableFrom = repo.Head.Tip, - ExcludeReachableFrom = baseCommit, - FirstParentOnly = true, - SortBy = CommitSortStrategies.Topological, - }); - } - - private static void AddChangedPaths(Repository repo, Commit commit, HashSet paths) - { - var parentTree = commit.Parents.FirstOrDefault()?.Tree; - - foreach (var change in repo.Diff.Compare(parentTree, commit.Tree)) - { - paths.Add(change.Path); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeDetector.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeDetector.cs deleted file mode 100644 index f83f1dac..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeDetector.cs +++ /dev/null @@ -1,489 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Models.Git; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class GitChangeDetector - { - private readonly ILogger _logger; - private readonly ISupportedFileChecker _supportedFileChecker; - private readonly IGitService _gitService; - private Dictionary> _mainBranchCandidatesCache; - private ConcurrentDictionary _loggedNoMergeBaseKeysByRepo; - - public GitChangeDetector(ILogger logger, ISupportedFileChecker supportedFileChecker, IGitService gitService) - { - _logger = logger; - _supportedFileChecker = supportedFileChecker; - _gitService = gitService ?? throw new ArgumentNullException(nameof(gitService)); - } - - public void ClearMainBranchCandidatesCache(string gitRootPath = null) - { - MainBranchNames.ClearDefaultBranchCache(gitRootPath); - - if (_mainBranchCandidatesCache == null) - { - return; - } - - if (string.IsNullOrEmpty(gitRootPath)) - { - _mainBranchCandidatesCache = null; - ClearNoMergeBaseLogState(); - return; - } - - var key = gitRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - _mainBranchCandidatesCache.Remove(key); - if (_mainBranchCandidatesCache.Count == 0) - { - _mainBranchCandidatesCache = null; - } - - ClearNoMergeBaseLogState(gitRootPath); - } - - public virtual async Task> GetChangedFilesVsBaselineAsync(string gitRootPath, IReadOnlyCollection workspacePaths, ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver, string baselineCommit, CancellationToken cancellationToken = default) - { - return await Task.Run( - () => - { - try - { - if (string.IsNullOrEmpty(gitRootPath) || !Directory.Exists(gitRootPath)) - { - return new List(); - } - - var repoPath = Repository.Discover(gitRootPath); - if (string.IsNullOrEmpty(repoPath)) - { - return new List(); - } - - var effectivePaths = workspacePaths != null && workspacePaths.Count > 0 - ? workspacePaths - : (IReadOnlyCollection)new[] { gitRootPath }; - - using (var repo = new Repository(repoPath)) - { - var context = new ChangeDetectionContext(gitRootPath, effectivePaths, savedFilesTracker, openFilesObserver); - var changedFiles = GetChangedFilesFromRepository(repo, context, baselineCommit); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Found {changedFiles.Count} changed files vs baseline"); - #endif - return changedFiles; - } - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error getting changed files", ex); - return new List(); - } - }, - cancellationToken); - } - - public virtual List GetMainBranchCandidates(Repository repo) - { - var gitRoot = repo.Info.WorkingDirectory?.TrimEnd(Path.DirectorySeparatorChar); - if (string.IsNullOrEmpty(gitRoot)) - { - return new List(); - } - - if (_mainBranchCandidatesCache != null && - _mainBranchCandidatesCache.TryGetValue(gitRoot, out var cached)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Returning cached main branch candidates ({cached.Count} candidates)"); - #endif - return cached; - } - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeDetector: Computing main branch candidates"); - #endif - - var localBranches = repo.Branches - .Where(b => !b.IsRemote) - .Select(b => b.FriendlyName) - .ToList(); - - var defaultBranch = MainBranchNames.GetDefaultBranch(repo); - if (!string.IsNullOrEmpty(defaultBranch)) - { - var singleCandidate = new List { defaultBranch }; - if (_mainBranchCandidatesCache == null) - { - _mainBranchCandidatesCache = new Dictionary>(); - } - - _mainBranchCandidatesCache[gitRoot] = singleCandidate; - return singleCandidate; - } - - var candidates = MainBranchNames.All - .Where(name => localBranches.Contains(name)) - .ToList(); - - if (_mainBranchCandidatesCache == null) - { - _mainBranchCandidatesCache = new Dictionary>(); - } - - _mainBranchCandidatesCache[gitRoot] = candidates; - - return candidates; - } - - protected virtual List GetChangedFilesFromRepository(Repository repo, ChangeDetectionContext context, string baselineCommit) - { - var currentBranch = repo.Head?.FriendlyName ?? "unknown"; - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Getting changed files from repository on branch '{currentBranch}'"); - #endif - - Commit baseCommit = null; - if (!string.IsNullOrEmpty(baselineCommit)) - { - baseCommit = repo.Lookup(baselineCommit); - } - - if (baseCommit == null) - { - var (computedBase, noMergeBaseCandidates) = GetMergeBaseCommit(repo); - baseCommit = computedBase; - if (baseCommit == null) - { - LogNoMergeBaseStateOnce(repo, noMergeBaseCandidates); - } - } - - var filesToExclude = BuildExclusionSet(context.SavedFilesTracker, context.OpenFilesObserver); - var committedChanges = GetCommittedChanges(repo, baseCommit, context.GitRootPath, context.WorkspacePaths); - var statusChanges = GetStatusChanges(repo, filesToExclude, context.GitRootPath, context.WorkspacePaths); - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Found {committedChanges.Count} committed changes and {statusChanges.Count} status changes"); - #endif - - var changedFiles = new List(); - changedFiles.AddRange(committedChanges); - changedFiles.AddRange(statusChanges); - - return changedFiles.Distinct().ToList(); - } - - protected virtual (Commit baseCommit, IReadOnlyList noMergeBaseCandidates) GetMergeBaseCommit(Repository repo) - { - try - { - var currentBranch = repo.Head; - if (!IsValidBranch(currentBranch)) - { - return (null, null); - } - - var mainBranchCandidates = GetMainBranchCandidates(repo); - if (mainBranchCandidates.Count == 0) - { - return (null, mainBranchCandidates); - } - - foreach (var candidateName in mainBranchCandidates) - { - var mergeBase = TryFindMergeBase(repo, currentBranch, candidateName); - if (mergeBase != null) - { - ClearNoMergeBaseLogState(repo.Info.WorkingDirectory); - return (mergeBase, mainBranchCandidates); - } - } - - return (null, mainBranchCandidates); - } - catch (Exception ex) - { - _logger?.Debug($"GitChangeObserver: Could not determine merge base: {ex.Message}"); - return (null, null); - } - } - - protected virtual Commit TryFindMergeBase(Repository repo, Branch currentBranch, string candidateName) - { - var mainBranch = repo.Branches[candidateName] - ?? repo.Branches[$"origin/{candidateName}"]; - if (!IsValidBranch(mainBranch)) - { - return null; - } - - if (IsOnMainBranch(currentBranch, mainBranch)) - { - return null; - } - - try - { - var mergeBase = repo.ObjectDatabase.FindMergeBase(currentBranch.Tip, mainBranch.Tip); - if (mergeBase != null) - { - _logger?.Debug($"GitChangeObserver: Found merge base using branch '{candidateName}'"); - } - - return mergeBase; - } - catch (Exception ex) - { - _logger?.Debug($"GitChangeObserver: Could not find merge base with '{candidateName}': {ex.Message}"); - return null; - } - } - - protected virtual List GetCommittedChanges(Repository repo, Commit baseCommit, string gitRootPath, IReadOnlyCollection workspacePaths) - { - var changes = new List(); - - try - { - if (baseCommit == null || repo.Head?.Tip == null) - { - return changes; - } - - var changedPaths = FirstParentChangeCollector.CollectChangedPaths(repo, baseCommit); - var relativePaths = changedPaths.Where(path => ShouldIncludeCommittedChange(path, gitRootPath)).ToList(); - AddGitRelativePathsInWorkspace(relativePaths, gitRootPath, workspacePaths, changes); - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Collected {changes.Count} committed changes"); - #endif - } - catch (Exception ex) - { - _logger?.Debug($"GitChangeObserver: Error getting committed changes: {ex.Message}"); - } - - return changes; - } - - protected virtual List GetStatusChanges(Repository repo, HashSet filesToExclude, string gitRootPath, IReadOnlyCollection workspacePaths) - { - var changes = new List(); - - try - { - var status = repo.RetrieveStatus(); - var relativePaths = status.Where(item => ShouldIncludeStatusItem(item, filesToExclude, gitRootPath)).Select(item => item.FilePath).ToList(); - AddGitRelativePathsInWorkspace(relativePaths, gitRootPath, workspacePaths, changes); - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Collected {changes.Count} status changes"); - #endif - } - catch (Exception ex) - { - _logger?.Debug($"GitChangeObserver: Error getting status changes: {ex.Message}"); - } - - return changes; - } - - private void AddGitRelativePathsInWorkspace(IEnumerable gitRelativePaths, string gitRootPath, IReadOnlyCollection workspacePaths, List output) - { - foreach (var path in gitRelativePaths) - { - if (IsFileInAnyWorkspace(path, gitRootPath, workspacePaths)) - { - output.Add(path.Replace('\\', '/')); - } - } - } - - private bool ShouldIncludeCommittedChange(string relativePath, string gitRootPath) - { - var fullPath = Path.Combine(gitRootPath, relativePath); - if (!File.Exists(fullPath) || _gitService.IsFileIgnored(fullPath)) - { - return false; - } - - return _supportedFileChecker.IsSupported(fullPath); - } - - private HashSet BuildExclusionSet(ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver) - { - var exclusionSet = new HashSet(StringComparer.OrdinalIgnoreCase); - - AddFilesToExclusionSet(exclusionSet, savedFilesTracker?.GetSavedFiles()); - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: Built exclusion set with {exclusionSet.Count} files"); - #endif - return exclusionSet; - } - - private void AddFilesToExclusionSet(HashSet exclusionSet, IEnumerable files) - { - if (files == null) - { - return; - } - - foreach (var file in files) - { - exclusionSet.Add(file); - } - } - - private void ClearNoMergeBaseLogState(string gitRootPath = null) - { - if (_loggedNoMergeBaseKeysByRepo == null) - { - return; - } - - if (string.IsNullOrEmpty(gitRootPath)) - { - _loggedNoMergeBaseKeysByRepo = null; - return; - } - - var key = gitRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - _loggedNoMergeBaseKeysByRepo.TryRemove(key, out _); - if (_loggedNoMergeBaseKeysByRepo.Count == 0) - { - _loggedNoMergeBaseKeysByRepo = null; - } - } - - private void LogNoMergeBaseStateOnce(Repository repo, IReadOnlyList candidates) - { - if (!TryMarkNoMergeBaseLogged(repo, candidates)) - { - return; - } - - if (candidates == null || candidates.Count == 0) - { - _logger?.Debug("GitChangeObserver: No main branch candidates found"); - } - else - { - _logger?.Debug("GitChangeObserver: No merge base found with any main branch candidate"); - } - - _logger?.Debug("GitChangeObserver: No merge base commit found, using working directory changes only"); - } - - private bool TryMarkNoMergeBaseLogged(Repository repo, IReadOnlyList candidates) - { - var gitRoot = repo?.Info?.WorkingDirectory?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.IsNullOrEmpty(gitRoot) || repo?.Head?.Tip == null) - { - return false; - } - - var candidateKey = candidates == null ? string.Empty : string.Join(",", candidates); - var stateKey = $"{repo.Head.Tip.Sha}|{candidateKey}"; - - if (_loggedNoMergeBaseKeysByRepo == null) - { - _loggedNoMergeBaseKeysByRepo = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - - if (_loggedNoMergeBaseKeysByRepo.TryGetValue(gitRoot, out var lastStateKey) && - string.Equals(lastStateKey, stateKey, StringComparison.Ordinal)) - { - return false; - } - - _loggedNoMergeBaseKeysByRepo[gitRoot] = stateKey; - return true; - } - - private bool IsValidBranch(Branch branch) - { - return branch != null && branch.Tip != null; - } - - private bool IsOnMainBranch(Branch currentBranch, Branch mainBranch) - { - return currentBranch.FriendlyName == mainBranch.FriendlyName; - } - - private bool ShouldIncludeStatusItem(StatusEntry item, HashSet filesToExclude, string gitRootPath) - { - if (IsUnalteredOrIgnored(item)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: File excluded - unaltered or ignored: {item.FilePath}"); - #endif - return false; - } - - var fullPath = Path.Combine(gitRootPath, item.FilePath); - - if (IsExcluded(fullPath, filesToExclude)) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: File excluded - in exclusion set: {item.FilePath}"); - #endif - return false; - } - - var isSupported = _supportedFileChecker.IsSupported(fullPath); - if (!isSupported) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeDetector: File excluded - unsupported file type: {item.FilePath}"); - #endif - } - - return isSupported; - } - - private bool IsUnalteredOrIgnored(StatusEntry item) - { - return item.State == FileStatus.Unaltered || item.State == FileStatus.Ignored; - } - - private bool IsExcluded(string fullPath, HashSet filesToExclude) - { - return _gitService.IsFileIgnored(fullPath) || filesToExclude.Contains(fullPath); - } - - private bool IsFileInAnyWorkspace(string gitRelativePath, string gitRootPath, IReadOnlyCollection workspacePaths) - { - if (workspacePaths == null || workspacePaths.Count == 0) - { - return true; - } - - try - { - var absolutePath = Path.GetFullPath(Path.Combine(gitRootPath, gitRelativePath)); - return File.Exists(absolutePath) && GitPathHelper.IsPathUnderAnyRoot(absolutePath, workspacePaths); - } - catch - { - return false; - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeLister.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeLister.cs deleted file mode 100644 index 9e2a729a..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeLister.cs +++ /dev/null @@ -1,606 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Util; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class GitChangeLister : IGitChangeLister, IDisposable - { - private readonly int _basePollingInterval; - private readonly int _pollingInterval; // Calculated based on core count, may differ from base. - private readonly ISavedFilesTracker _savedFilesTracker; - private readonly ISupportedFileChecker _supportedFileChecker; - private readonly ILogger _logger; - private readonly IGitService _gitService; - private readonly IIdeActivityTracker _ideActivityTracker; - private readonly ICpuUsageChecker _cpuUsageChecker; - private readonly UntrackedFileProcessor _untrackedFileProcessor; - private readonly MergeBaseFinder _mergeBaseFinder; - - private string _gitRootPath; - private IReadOnlyCollection _workspacePaths; - private DroppingScheduledExecutor _scheduledExecutor; - private ConcurrentDictionary _loggedNoMergeBaseWarnKeysByRepo; - private bool _disposed = false; - - public GitChangeLister( - ISavedFilesTracker savedFilesTracker, - ISupportedFileChecker supportedFileChecker, - ILogger logger, - IGitService gitService, - int? pollingInterval = null, - IIdeActivityTracker ideActivityTracker = null, - ICpuUsageChecker cpuUsageChecker = null) - { - _savedFilesTracker = savedFilesTracker ?? throw new ArgumentNullException(nameof(savedFilesTracker)); - _supportedFileChecker = supportedFileChecker ?? throw new ArgumentNullException(nameof(supportedFileChecker)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _gitService = gitService ?? throw new ArgumentNullException(nameof(gitService)); - _ideActivityTracker = ideActivityTracker; - _cpuUsageChecker = cpuUsageChecker; - _untrackedFileProcessor = new UntrackedFileProcessor(logger); - _mergeBaseFinder = new MergeBaseFinder(logger); - if (pollingInterval.HasValue && pollingInterval.Value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(pollingInterval), pollingInterval.Value, "Polling interval must be greater than 0."); - } - - _pollingInterval = pollingInterval ?? CalculatePollingInterval(); - _basePollingInterval = _pollingInterval; - } - - public event EventHandler> FilesDetected; - - public virtual async Task> GetAllChangedFilesAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - return await GetAllChangedFilesAsync(gitRootPath, string.IsNullOrEmpty(workspacePath) ? null : new[] { workspacePath }, cancellationToken); - } - - public virtual async Task> GetAllChangedFilesAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var result = await ExecuteGitOperationAsync(gitRootPath, workspacePaths, "getting all changed files", cancellationToken, repo => - { - var statusFiles = CollectFilesFromRepoState(repo, gitRootPath, workspacePaths); - var diffFiles = CollectFilesFromGitDiff(repo, gitRootPath, workspacePaths); - var allFiles = new HashSet(statusFiles); - allFiles.UnionWith(diffFiles); - return allFiles; - }); - cancellationToken.ThrowIfCancellationRequested(); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: GetAllChangedFilesAsync found {result.Count} files"); -#endif - return result; - } - - public virtual Task> GetChangedFilesVsMergeBaseAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default) - { - return GetChangedFilesVsMergeBaseAsync(gitRootPath, string.IsNullOrEmpty(workspacePath) ? null : new[] { workspacePath }, cancellationToken); - } - - public virtual Task> GetChangedFilesVsMergeBaseAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - return ExecuteAndLogAsync(gitRootPath, workspacePaths, "getting changed files vs merge base", "GetChangedFilesVsMergeBaseAsync found", GetChangedFilesVsMergeBase, cancellationToken); - } - - public void Initialize(string gitRootPath, IReadOnlyCollection workspacePaths) - { - _gitRootPath = gitRootPath; - _workspacePaths = workspacePaths ?? Array.Empty(); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Initialized with gitRoot='{gitRootPath}', workspacePaths count={_workspacePaths.Count}"); -#endif - } - - public void SetWorkspacePaths(IReadOnlyCollection workspacePaths) - { - _workspacePaths = workspacePaths ?? Array.Empty(); - } - - public void StartPeriodicScanning(CancellationToken cancellationToken) - { - if (_disposed) - { - return; - } - - if (_scheduledExecutor != null) - { - _logger?.Warn("GitChangeLister: Periodic scanning already started"); - return; - } - - _scheduledExecutor = new DroppingScheduledExecutor( - PeriodicScanAsync, - cancellationToken, - TimeSpan.FromSeconds(_pollingInterval), - _logger); - - _scheduledExecutor.Start(); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Started periodic scanning with {_pollingInterval} second interval"); -#endif - } - - public void StopPeriodicScanning() - { - _scheduledExecutor?.Stop(); - _scheduledExecutor = null; -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeLister: Stopped periodic scanning"); -#endif - } - - public virtual Task> CollectFilesFromRepoStateAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default) - { - return ExecuteAndLogAsync(gitRootPath, workspacePaths, "collecting files from repo state", "CollectFilesFromRepoStateAsync collected", CollectFilesFromRepoState, cancellationToken); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeLister: Disposing and cleaning up resources"); -#endif - StopPeriodicScanning(); - _scheduledExecutor?.Dispose(); - _scheduledExecutor = null; - _disposed = true; - } - - protected virtual HashSet CollectFilesFromRepoState(Repository repo, string gitRootPath, IReadOnlyCollection workspacePaths) - { - var changedFiles = new HashSet(); - - try - { - var status = repo.RetrieveStatus(); - var untrackedByDirectory = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var savedFiles = new HashSet(_savedFilesTracker.GetSavedFiles(), StringComparer.OrdinalIgnoreCase); - - var (trackedCandidates, untrackedCandidates) = CategorizeStatusItems(status, gitRootPath, workspacePaths); - - var nonIgnoredTracked = _gitService.FilterIgnoredFiles(trackedCandidates); - changedFiles.UnionWith(nonIgnoredTracked); - - var untrackedAbsolutePaths = untrackedCandidates.Select(x => x.AbsolutePath); - var nonIgnoredUntracked = _gitService.FilterIgnoredFiles(untrackedAbsolutePaths); - AddNonIgnoredUntrackedFiles(untrackedCandidates, nonIgnoredUntracked, untrackedByDirectory); - - _untrackedFileProcessor.ProcessUntrackedDirectories(untrackedByDirectory, savedFiles, changedFiles); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: CollectFilesFromRepoState collected {changedFiles.Count} files from repo state"); -#endif - } - catch (Exception ex) - { - _logger?.Error("GitChangeLister: Error collecting files from repo state", ex); - } - - return changedFiles; - } - - protected virtual HashSet CollectFilesFromGitDiff(Repository repo, string gitRootPath, IReadOnlyCollection workspacePaths) - { - try - { - var relativePaths = GetChangedFilesVsMergeBase(repo, gitRootPath, workspacePaths); - var result = ConvertAndFilterPaths(relativePaths, gitRootPath); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: CollectFilesFromGitDiff collected {result.Count} files from git diff"); -#endif - return result; - } - catch (Exception ex) - { - _logger?.Error("GitChangeLister: Error collecting files from git diff", ex); - return new HashSet(); - } - } - - protected HashSet ConvertAndFilterPaths(IEnumerable relativePaths, string gitRootPath) - { - var candidates = new List(); - foreach (var relativePath in relativePaths) - { - var absolutePath = GitPathHelper.ConvertToAbsolutePath(relativePath, gitRootPath); - if (File.Exists(absolutePath) && ShouldReviewFile(absolutePath)) - { - candidates.Add(absolutePath); - } - } - - return _gitService.FilterIgnoredFiles(candidates); - } - - /// - /// Dynamically set polling interval based on performance of the machine. - /// - private static int CalculatePollingInterval() - { - var coreCount = Environment.ProcessorCount; - if (coreCount >= 6) - { - return 9; - } - - if (coreCount >= 3) - { - return 18; - } - - return 32; - } - - private async Task> ExecuteGitOperationAsync( - string gitRootPath, - IReadOnlyCollection workspacePathsUnused, - string operationName, - CancellationToken cancellationToken, - Func> operation) - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Starting operation '{operationName}'"); -#endif - return await Task.Run( - () => - { - try - { - cancellationToken.ThrowIfCancellationRequested(); - if (!IsValidGitRoot(gitRootPath)) - { - return new HashSet(); - } - - var repoPath = Repository.Discover(gitRootPath); - if (string.IsNullOrEmpty(repoPath)) - { - return new HashSet(); - } - - using (var repo = new Repository(repoPath)) - { - cancellationToken.ThrowIfCancellationRequested(); - return operation(repo); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger?.Error($"GitChangeLister: Error {operationName}", ex); - return new HashSet(); - } - }, - cancellationToken); - } - - private async Task> ExecuteAndLogAsync( - string gitRootPath, - IReadOnlyCollection workspacePaths, - string operationName, - string logLabel, - Func, HashSet> repoOperation, - CancellationToken cancellationToken = default) - { - var result = await ExecuteGitOperationAsync(gitRootPath, workspacePaths, operationName, cancellationToken, repo => repoOperation(repo, gitRootPath, workspacePaths)); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: {logLabel} {result.Count} files"); -#endif - return result; - } - - private async Task PeriodicScanAsync(CancellationToken cancellationToken) - { - var startTime = DateTime.UtcNow; - try - { - if (!await ShouldStartPeriodicScanAsync()) - { - return; - } - - _logger?.Info("Starting scheduled git change review"); - cancellationToken.ThrowIfCancellationRequested(); - var didCleanup = ReviewCacheCleanup.CleanupCaches(_gitRootPath); - var files = await GetAllChangedFilesAsync(_gitRootPath, _workspacePaths, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - if (didCleanup) - { - files ??= new HashSet(); - files.Add("~~cleanup~~"); - } - - if (files == null || files.Count == 0) - { - return; - } - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Periodic scan detected {files.Count} files"); -#endif - FilesDetected?.Invoke(this, files); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger?.Error("GitChangeLister: Error during periodic scan", ex); - } - finally - { - AdjustIntervalIfNeeded(startTime); - } - } - - private void AdjustIntervalIfNeeded(DateTime startTime) - { - var elapsedSeconds = (int)Math.Ceiling((DateTime.UtcNow - startTime).TotalSeconds); - _logger?.Debug($"Scheduled git change review completed in {elapsedSeconds}s"); - - var executor = _scheduledExecutor; - if (elapsedSeconds > _basePollingInterval && executor != null) - { - var newPeriodSeconds = (_basePollingInterval * 2) + elapsedSeconds; - var currentPeriodSeconds = (int)executor.GetInterval().TotalSeconds; - if (newPeriodSeconds > currentPeriodSeconds) - { - executor.SetInterval(TimeSpan.FromSeconds(newPeriodSeconds)); - _logger?.Info($"Git change review took {elapsedSeconds}s, increased period to {newPeriodSeconds}s"); - } - } - } - - private async Task ShouldStartPeriodicScanAsync() - { - if (_ideActivityTracker != null && !_ideActivityTracker.IsIdeWindowActive()) - { - return false; - } - - if (DeltaJobTracker.IsAnalysisRunning) - { - _logger?.Info("Skipping scheduled git change review: analysis in progress"); - return false; - } - - if (_cpuUsageChecker != null && await _cpuUsageChecker.IsCpuTooBusyAsync()) - { - _logger?.Info("Skipping scheduled git change review: CPU usage too high"); - return false; - } - - return true; - } - - private bool IsValidGitRoot(string gitRootPath) - { - var isValid = !string.IsNullOrEmpty(gitRootPath) && Directory.Exists(gitRootPath); - if (!isValid) - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Invalid git root path '{gitRootPath}'"); -#endif - } - - return isValid; - } - - private HashSet GetChangedFilesVsMergeBase(Repository repo, string gitRootPath, IReadOnlyCollection workspacePaths) - { - var currentBranch = repo.Head?.FriendlyName ?? "unknown"; -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: Getting changed files vs merge base on branch '{currentBranch}'"); -#endif - - var mergeBase = _mergeBaseFinder.GetMergeBaseCommit(repo); - if (mergeBase == null) - { - LogNoMergeBaseWarnOnce(repo); - return new HashSet(); - } - - ClearNoMergeBaseWarnLogState(repo.Info.WorkingDirectory); - - if (repo.Head?.Tip == null) - { - return new HashSet(); - } - - return GetCommittedChanges(repo, mergeBase, gitRootPath, workspacePaths); - } - - private HashSet GetCommittedChanges( - Repository repo, - Commit mergeBase, - string gitRootPath, - IReadOnlyCollection workspacePaths) - { - var changedPaths = FirstParentChangeCollector.CollectChangedPaths(repo, mergeBase); - var candidates = CollectDiffCandidates(changedPaths, gitRootPath, workspacePaths); - - var candidatePaths = candidates.Select(c => c.FullPath); - var nonIgnored = _gitService.FilterIgnoredFiles(candidatePaths); - - var changedFiles = new HashSet( - candidates.Where(c => nonIgnored.Contains(c.FullPath)).Select(c => c.RelativePath)); - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeLister: GetCommittedChanges found {changedFiles.Count} committed changes"); -#endif - return changedFiles; - } - - private List<(string RelativePath, string FullPath)> CollectDiffCandidates( - IEnumerable changedPaths, - string gitRootPath, - IReadOnlyCollection workspacePaths) - { - var candidates = new List<(string RelativePath, string FullPath)>(); - - foreach (var relativePath in changedPaths) - { - var fullPath = Path.Combine(gitRootPath, relativePath); - - if (!File.Exists(fullPath)) - { - continue; - } - - if (GitPathHelper.IsFileInWorkspace(relativePath, gitRootPath, workspacePaths)) - { - candidates.Add((relativePath, fullPath)); - } - } - - return candidates; - } - - private bool ShouldSkipStatusItem(StatusEntry item) - { - return item.State == FileStatus.Unaltered || - item.State == FileStatus.Ignored || - item.State.HasFlag(FileStatus.DeletedFromWorkdir) || - item.State.HasFlag(FileStatus.DeletedFromIndex); - } - - private bool ShouldReviewFile(string absolutePath) - { - return _supportedFileChecker.IsSupported(absolutePath); - } - - private void LogNoMergeBaseWarnOnce(Repository repo) - { - if (repo?.Head == null || _mergeBaseFinder.IsMainBranch(repo, repo.Head.FriendlyName)) - { - return; - } - - if (!TryMarkNoMergeBaseWarnLogged(repo)) - { - return; - } - - _logger?.Warn("GitChangeLister: On non-main branch but can't determine merge-base"); - } - - private void ClearNoMergeBaseWarnLogState(string gitRootPath = null) - { - if (_loggedNoMergeBaseWarnKeysByRepo == null) - { - return; - } - - if (string.IsNullOrEmpty(gitRootPath)) - { - _loggedNoMergeBaseWarnKeysByRepo = null; - return; - } - - var key = gitRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - _loggedNoMergeBaseWarnKeysByRepo.TryRemove(key, out _); - if (_loggedNoMergeBaseWarnKeysByRepo.Count == 0) - { - _loggedNoMergeBaseWarnKeysByRepo = null; - } - } - - private bool TryMarkNoMergeBaseWarnLogged(Repository repo) - { - var gitRoot = repo?.Info?.WorkingDirectory?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.IsNullOrEmpty(gitRoot) || repo?.Head?.Tip == null) - { - return false; - } - - var defaultBranch = MainBranchNames.GetDefaultBranch(repo) ?? string.Empty; - var stateKey = $"{repo.Head.Tip.Sha}|{repo.Head.FriendlyName}|{defaultBranch}"; - - if (_loggedNoMergeBaseWarnKeysByRepo == null) - { - _loggedNoMergeBaseWarnKeysByRepo = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - - if (_loggedNoMergeBaseWarnKeysByRepo.TryGetValue(gitRoot, out var lastStateKey) && - string.Equals(lastStateKey, stateKey, StringComparison.Ordinal)) - { - return false; - } - - _loggedNoMergeBaseWarnKeysByRepo[gitRoot] = stateKey; - return true; - } - - private (List Tracked, List<(string RelativePath, string AbsolutePath)> Untracked) CategorizeStatusItems( - RepositoryStatus status, - string gitRootPath, - IReadOnlyCollection workspacePaths) - { - var trackedCandidates = new List(); - var untrackedCandidates = new List<(string RelativePath, string AbsolutePath)>(); - - foreach (var item in status) - { - if (ShouldSkipStatusItem(item)) - { - continue; - } - - var absolutePath = GitPathHelper.ConvertToAbsolutePath(item.FilePath, gitRootPath); - - if (!GitPathHelper.IsFileInWorkspace(item.FilePath, gitRootPath, workspacePaths) || !ShouldReviewFile(absolutePath)) - { - continue; - } - - if (item.State == FileStatus.NewInWorkdir) - { - untrackedCandidates.Add((item.FilePath, absolutePath)); - } - else - { - trackedCandidates.Add(absolutePath); - } - } - - return (trackedCandidates, untrackedCandidates); - } - - private void AddNonIgnoredUntrackedFiles( - List<(string RelativePath, string AbsolutePath)> untrackedCandidates, - HashSet nonIgnoredUntracked, - Dictionary> untrackedByDirectory) - { - foreach (var candidate in untrackedCandidates) - { - if (nonIgnoredUntracked.Contains(candidate.AbsolutePath)) - { - _untrackedFileProcessor.AddUntrackedFileToDirectory(candidate.RelativePath, candidate.AbsolutePath, untrackedByDirectory); - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DefaultBranchGate.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DefaultBranchGate.cs deleted file mode 100644 index 25cb6d29..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DefaultBranchGate.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Application.Git -{ - public partial class GitChangeObserverCore - { - private void InitializeDefaultBranchGate() - { - if (!string.IsNullOrEmpty(_gitRootPath)) - { - _defaultBranchGate = new DefaultBranchGate(_gitRootPath); - } - } - - private bool ShouldSkipBasedOnDefaultBranch() - { - return _defaultBranchGate?.ShouldSkipForCurrentBranch() ?? false; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DetectedFiles.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DetectedFiles.cs deleted file mode 100644 index 42a503d3..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DetectedFiles.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Util; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public partial class GitChangeObserverCore - { - private void RemoveStaleFilesFromCache(HashSet changedFiles) - { - try - { - var filesToKeep = BuildFilesToKeep(changedFiles); - var removedCount = new DeltaCacheService().RemoveEntriesNotIn(filesToKeep); - - if (removedCount > 0) - { - _logger?.Debug($"Removed {removedCount} stale files from delta cache"); - ViewUpdateRequested?.Invoke(this, EventArgs.Empty); - } - } - catch (Exception ex) - { - _logger?.Warn($"Error removing stale files: {ex.Message}"); - } - } - - private HashSet BuildFilesToKeep(HashSet changedFiles) - { - var filesToKeep = new HashSet(changedFiles, StringComparer.OrdinalIgnoreCase); - - AddVisibleFiles(filesToKeep); - AddRunningJobFiles(filesToKeep); - - return filesToKeep; - } - - private void AddVisibleFiles(HashSet filesToKeep) - { - var visibleFiles = _openFilesObserver?.GetAllVisibleFileNames(); - if (visibleFiles == null) - { - return; - } - - foreach (var f in visibleFiles) - { - filesToKeep.Add(f); - } - } - - private void AddRunningJobFiles(HashSet filesToKeep) - { - foreach (var job in DeltaJobTracker.RunningJobs) - { - var fileName = job.File?.FileName; - if (!string.IsNullOrEmpty(fileName)) - { - filesToKeep.Add(fileName); - } - } - } - - private async Task ProcessDetectedFileQueueAsync(string filePath, CancellationToken token, string baselineCommit) - { - var currentRequest = filePath; - while (true) - { - if (token.IsCancellationRequested) - { - return; - } - - try - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: GitChangeLister detected 1 file"); -#endif - await ProcessFilesAsync(new[] { currentRequest }, token, baselineCommit); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Processed detected files"); -#endif - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error processing detected files", ex); - } - - if (!_detectedFilesQueue.CompleteAndGetNext(filePath, out currentRequest)) - { - return; - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.cs deleted file mode 100644 index bb627577..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.cs +++ /dev/null @@ -1,670 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Cache.Review; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Enums.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Util; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public partial class GitChangeObserverCore : IDisposable - { - private readonly ILogger _logger; - private readonly ICodeReviewer _codeReviewer; - private readonly ISupportedFileChecker _supportedFileChecker; - private readonly IAsyncTaskScheduler _taskScheduler; - private readonly IGitChangeLister _gitChangeLister; - private readonly IGitService _gitService; - - private TrackerManager _trackerManager; - private FileSystemWatcher _fileWatcher; - private FileSystemEventHandler _watcherCreatedHandler; - private FileSystemEventHandler _watcherChangedHandler; - private FileSystemEventHandler _watcherDeletedHandler; - - private FileChangeEventProcessor _eventProcessor; - private string _solutionPath; - private string _workspacePath; - private string _gitRootPath; - private IReadOnlyCollection _workspacePaths; - private CancellationTokenSource _cts; - - private ISavedFilesTracker _savedFilesTracker; - private IOpenFilesObserver _openFilesObserver; - private GitChangeDetector _gitChangeDetector; - private FileChangeHandler _fileChangeHandler; - private Func>> _getChangedFilesCallback; - private CodesceneFileWatcher _rulesFileWatcher; - private CodesceneFileWatcher _configFileWatcher; - private GitIgnoreWatcher _gitIgnoreWatcher; - private DefaultBranchGate _defaultBranchGate; - private TaskCompletionSource _initializationComplete; - private PerFileRequestQueue _detectedFilesQueue = new PerFileRequestQueue(); - private ConcurrentDictionary _perFileProcessingLocks = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public GitChangeObserverCore( - ILogger logger, - ICodeReviewer codeReviewer, - ISupportedFileChecker supportedFileChecker, - IAsyncTaskScheduler taskScheduler, - IGitChangeLister gitChangeLister, - IGitService gitService) - { - _logger = logger; - _codeReviewer = codeReviewer; - _supportedFileChecker = supportedFileChecker; - _taskScheduler = taskScheduler; - _gitChangeLister = gitChangeLister; - _gitService = gitService ?? throw new ArgumentNullException(nameof(gitService)); - _trackerManager = new TrackerManager(_logger); - } - - public event EventHandler FileDeletedFromGit; - - public event EventHandler ViewUpdateRequested; - - public ConcurrentQueue EventQueue => _eventProcessor?.EventQueue; - - public FileSystemWatcher FileWatcher => _fileWatcher; - - public Timer ScheduledTimer => _eventProcessor?.ScheduledTimer; - - public void Initialize(string solutionPath, ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver, Func>> getChangedFilesCallback = null, IOpenDocumentContentProvider openDocumentContentProvider = null, IReadOnlyCollection watchPaths = null) - { - if (savedFilesTracker == null) - { - throw new ArgumentNullException(nameof(savedFilesTracker), "SavedFilesTracker must be provided to GitChangeObserver"); - } - - if (openFilesObserver == null) - { - throw new ArgumentNullException(nameof(openFilesObserver), "OpenFilesObserver must be provided to GitChangeObserver"); - } - - _solutionPath = solutionPath; - _savedFilesTracker = savedFilesTracker; - _openFilesObserver = openFilesObserver; - _getChangedFilesCallback = getChangedFilesCallback ?? GetChangedFilesVsBaselineAsync; - _gitChangeDetector = new GitChangeDetector(_logger, _supportedFileChecker, _gitService); - _cts = new CancellationTokenSource(); - - InitializeGitPaths(); - InitializeDefaultBranchGate(); - LogIdentifiedBaselineBranch(); - - _eventProcessor = new FileChangeEventProcessor(_logger, _taskScheduler, ProcessEventAsync, _getChangedFilesCallback, ShouldSkipBasedOnDefaultBranch, () => _gitService.GetBaselineCommit(_gitRootPath)); - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Initialized with solution='{_solutionPath}', gitRoot='{_gitRootPath}', workspace='{_workspacePath}'"); -#endif - - _workspacePaths = watchPaths != null && watchPaths.Count > 0 ? watchPaths : new[] { _workspacePath }; - _gitChangeLister.Initialize(_gitRootPath, _workspacePaths); - _gitChangeLister.FilesDetected += OnGitChangeListerFilesDetected; - - _fileChangeHandler = new FileChangeHandler(_logger, _codeReviewer, _supportedFileChecker, _workspacePaths, _trackerManager, _gitService, _gitRootPath, OnFileDeleted, openDocumentContentProvider); - _fileChangeHandler.FileDeletedFromGit += (sender, args) => FileDeletedFromGit?.Invoke(this, args); - - if (!string.IsNullOrEmpty(_gitRootPath) && Directory.Exists(_gitRootPath)) - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Creating file watcher for git root '{_gitRootPath}'"); -#endif - _fileWatcher = GitPathDiscovery.CreateWatcher(_gitRootPath); - } - - _rulesFileWatcher = new CodesceneFileWatcher( - _gitRootPath, - CodesceneFileWatcher.CodeHealthRulesFileName, - _logger, - "Code health rules change detected."); - _rulesFileWatcher.FileChanged += OnCodeHealthRulesChanged; - - _configFileWatcher = new CodesceneFileWatcher( - _gitRootPath, - CodesceneFileWatcher.ConfigFileName, - _logger, - "CodeScene config change detected."); - _configFileWatcher.FileChanged += OnCodesceneConfigChanged; - - _gitIgnoreWatcher = new GitIgnoreWatcher(_gitRootPath, _logger); - _gitIgnoreWatcher.GitIgnoreChanged += OnGitIgnoreChanged; - - InitializeTracker(); - } - - public void Start() - { - if (_fileWatcher == null) - { - _logger?.Warn($"GitChangeObserver: Cannot start - file watcher not initialized"); - return; - } - - if (_fileWatcher.EnableRaisingEvents) - { - return; - } - - _taskScheduler.Schedule(ct => StartWatcherAfterInitializationAsync(ct)); - } - - public virtual async Task> GetChangedFilesVsBaselineAsync(string baselineCommit) - { - return await _gitChangeDetector.GetChangedFilesVsBaselineAsync(_gitRootPath, _workspacePaths, _savedFilesTracker, _openFilesObserver, baselineCommit); - } - - public void UpdateWorkspacePaths(IReadOnlyCollection workspacePaths) - { - var newPaths = workspacePaths ?? Array.Empty(); - _workspacePaths = newPaths; - _fileChangeHandler?.SetWorkspacePaths(newPaths); - _gitChangeLister?.SetWorkspacePaths(newPaths); - } - - public void RemoveFromTracker(string filePath) - { - _trackerManager.Remove(filePath); - } - - public TrackerManager GetTrackerManager() - { - return _trackerManager; - } - - public async Task HandleFileChangeForTestingAsync(string filePath, List changedFiles) - { - await _fileChangeHandler.HandleFileChangeAsync(filePath, changedFiles); - } - - public async Task HandleFileDeleteForTestingAsync(string filePath, List changedFiles) - { - await _fileChangeHandler.HandleFileDeleteAsync(filePath, changedFiles); - } - - public bool ShouldProcessFileForTesting(string filePath, List changedFiles) - { - return _fileChangeHandler.ShouldProcessFile(filePath, changedFiles); - } - - public void Dispose() - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeObserverCore: Disposing and cleaning up resources"); -#endif - _gitChangeLister.FilesDetected -= OnGitChangeListerFilesDetected; - _gitChangeLister.StopPeriodicScanning(); - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - _initializationComplete?.TrySetCanceled(); - _initializationComplete = null; - _detectedFilesQueue.Clear(); - - if (_fileWatcher != null) - { - try - { - _fileWatcher.EnableRaisingEvents = false; - if (_watcherCreatedHandler != null) - { - _fileWatcher.Created -= _watcherCreatedHandler; - } - - if (_watcherChangedHandler != null) - { - _fileWatcher.Changed -= _watcherChangedHandler; - } - - if (_watcherDeletedHandler != null) - { - _fileWatcher.Deleted -= _watcherDeletedHandler; - } - } - catch - { - } - - _fileWatcher?.Dispose(); - _fileWatcher = null; - } - - _rulesFileWatcher?.Dispose(); - _rulesFileWatcher = null; - - _configFileWatcher?.Dispose(); - _configFileWatcher = null; - - _gitIgnoreWatcher?.Dispose(); - _gitIgnoreWatcher = null; - - _eventProcessor?.Dispose(); - _eventProcessor = null; - } - - public void CancelAndReset() - { - ResetCacheGenerationAndDeltaState(); - _gitChangeLister.FilesDetected -= OnGitChangeListerFilesDetected; - _gitChangeLister.StopPeriodicScanning(); - _eventProcessor?.DrainAndStop(); - - if (_gitIgnoreWatcher != null) - { - _gitIgnoreWatcher.GitIgnoreChanged -= OnGitIgnoreChanged; - } - - _cts?.Cancel(); - _cts?.Dispose(); - _cts = new CancellationTokenSource(); - - _initializationComplete?.TrySetCanceled(); - _initializationComplete = null; - _detectedFilesQueue.Clear(); - - ClearTrackerAndJobs(); - _gitChangeLister.FilesDetected += OnGitChangeListerFilesDetected; - if (_gitIgnoreWatcher != null) - { - _gitIgnoreWatcher.GitIgnoreChanged += OnGitIgnoreChanged; - } - - _eventProcessor?.Start(TimeSpan.FromSeconds(1), _cts.Token); - _gitChangeLister.StartPeriodicScanning(_cts.Token); - - InitializeTracker(); - } - - private static void ResetCacheGenerationAndDeltaState() - { - CacheGeneration.Increment(); - new DeltaCacheService().Clear(); - } - - private void ClearTrackerAndJobs() - { - _trackerManager.Clear(); - DeltaJobTracker.Clear(); - } - - private async Task StartWatcherAfterInitializationAsync(CancellationToken cancellationToken) - { - if (_initializationComplete != null) - { - try - { - await _initializationComplete.Task.ConfigureAwait(false); - } - catch - { - } - - if (_initializationComplete.Task.IsCanceled) - { - return; - } - } - - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (_fileWatcher == null) - { - return; - } - - if (_fileWatcher.EnableRaisingEvents) - { - return; - } - - BindWatcherEvents(_fileWatcher); - _fileWatcher.EnableRaisingEvents = true; - _eventProcessor.Start(TimeSpan.FromSeconds(1), _cts.Token); - - _gitChangeLister.StartPeriodicScanning(_cts.Token); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeObserverCore: Started file watcher and timer with 1 second interval"); -#endif - } - - private void OnFileDeleted(string filePath) - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Invalidating delta cache for deleted file '{filePath}'"); -#endif - ReviewCacheCleanup.InvalidateFile(filePath); - ViewUpdateRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnCodeHealthRulesChanged(object sender, EventArgs e) - { - InvalidateTrackedFiles(onlyIgnored: false); - } - - private void OnCodesceneConfigChanged(object sender, EventArgs e) - { - _gitChangeDetector?.ClearMainBranchCandidatesCache(_gitRootPath); - _defaultBranchGate?.InvalidateCache(); - InvalidateTrackedFiles(onlyIgnored: false); - } - - private void OnGitIgnoreChanged(object sender, EventArgs e) - { - InvalidateTrackedFiles(); - } - - private void InvalidateTrackedFiles(bool onlyIgnored = true) - { - _taskScheduler.Schedule(async () => - { - try - { - var trackedFiles = _trackerManager.GetAllTrackedFiles(); - foreach (var filePath in trackedFiles) - { - if (!onlyIgnored || _gitService.IsFileIgnored(filePath)) - { - _trackerManager.Remove(filePath); - ReviewCacheCleanup.InvalidateFile(filePath); - } - } - - ViewUpdateRequested?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error handling .gitignore change", ex); - } - }); - } - - private void OnGitChangeListerFilesDetected(object sender, HashSet absolutePaths) - { - try - { - var cts = _cts; - if (cts == null) - { - return; - } - - if (absolutePaths == null) - { - throw new ArgumentNullException(nameof(absolutePaths)); - } - - var validPaths = absolutePaths.Where(path => !string.IsNullOrWhiteSpace(path)).ToHashSet(); - RemoveStaleFilesFromCache(validPaths); - - var token = cts.Token; - var baselineCommit = _gitService.GetBaselineCommit(_gitRootPath); - foreach (var path in validPaths) - { - if (_detectedFilesQueue.TryStart(path, path)) - { - _taskScheduler.Schedule(async () => await ProcessDetectedFileQueueAsync(path, token, baselineCommit)); - } - else - { - _detectedFilesQueue.EnqueueLatest(path, path); - } - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error processing detected files", ex); - } - } - - private void InitializeGitPaths() - { - try - { - (_workspacePath, _gitRootPath) = GitPathDiscovery.Discover(_solutionPath); -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Git repository discovered successfully at '{_gitRootPath}'"); -#endif - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Could not discover git repository", ex); - _gitRootPath = _workspacePath = Directory.Exists(_solutionPath) ? _solutionPath : Path.GetDirectoryName(_solutionPath); - } - } - - private void LogIdentifiedBaselineBranch() - { - if (string.IsNullOrEmpty(_gitRootPath) || !Directory.Exists(_gitRootPath)) - { - return; - } - - try - { - using (var repo = new Repository(_gitRootPath)) - { - var baselineBranch = MainBranchNames.GetDefaultBranch(repo); - if (!string.IsNullOrEmpty(baselineBranch)) - { - _logger?.Info($"GitChangeObserver: Baseline branch is '{baselineBranch}'."); - return; - } - - var candidates = _gitChangeDetector.GetMainBranchCandidates(repo); - if (candidates.Count > 0) - { - _logger?.Info( - $"GitChangeObserver: Baseline branch candidates are {string.Join(", ", candidates)} (no config or origin/HEAD default)."); - return; - } - } - - _logger?.Info("GitChangeObserver: No baseline branch identified."); - } - catch (Exception ex) - { - _logger?.Warn($"GitChangeObserver: Could not determine baseline branch: {ex.Message}"); - } - } - - private void InitializeTracker() - { - var cts = _cts; - if (cts == null) - { - return; - } - - var initTcs = new TaskCompletionSource(); - _initializationComplete = initTcs; - var token = cts.Token; - _taskScheduler.Schedule(async () => - { - try - { - token.ThrowIfCancellationRequested(); - var absolutePaths = await _gitChangeLister.CollectFilesFromRepoStateAsync(_gitRootPath, _workspacePaths, token); - token.ThrowIfCancellationRequested(); - var baselineCommit = _gitService.GetBaselineCommit(_gitRootPath); - var changedFiles = await _getChangedFilesCallback(baselineCommit); - token.ThrowIfCancellationRequested(); - - // Add all files to tracker unconditionally - this ensures HandleFileDelete works correctly. - // Files open in the editor are excluded from changedFiles (via OpenFilesObserver), but they - // still need to be tracked so that delete events are properly handled. - foreach (var absolutePath in absolutePaths) - { - token.ThrowIfCancellationRequested(); - _trackerManager.Add(absolutePath); - } - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: Initialized tracker"); -#endif - initTcs.TrySetResult(true); - } - catch (OperationCanceledException) - { - initTcs.TrySetCanceled(); - } - catch (Exception ex) - { - _logger?.Error("GitChangeObserver: Error initializing tracker", ex); - initTcs.TrySetException(ex); - } - }); - } - - private async Task ProcessFilesAsync(IEnumerable absolutePaths, CancellationToken cancellationToken, string baselineCommit) - { - if (absolutePaths.Contains("~~cleanup~~")) - { - absolutePaths = absolutePaths.Where(x => x != "~~cleanup~~").ToList(); - ViewUpdateRequested?.Invoke(this, EventArgs.Empty); - } - - cancellationToken.ThrowIfCancellationRequested(); - var operationGeneration = CacheGeneration.Current; - var changedFiles = await _getChangedFilesCallback(baselineCommit); - await RemoveMissingTrackedFilesAsync(changedFiles, cancellationToken); - foreach (var absolutePath in absolutePaths) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (!File.Exists(absolutePath)) - { - continue; - } - - await ExecutePerFileAsync( - absolutePath, - cancellationToken, - () => _fileChangeHandler.HandleFileChangeAsync(absolutePath, changedFiles, operationGeneration, cancellationToken, baselineCommit)); - } - } - - private async Task RemoveMissingTrackedFilesAsync(List changedFiles, CancellationToken cancellationToken) - { - var trackedFiles = _trackerManager.GetAllTrackedFiles(); - foreach (var trackedFile in trackedFiles) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (File.Exists(trackedFile)) - { - continue; - } - - await _fileChangeHandler.HandleFileDeleteAsync(trackedFile, changedFiles, cancellationToken); - } - } - - private async Task ExecutePerFileAsync(string filePath, CancellationToken cancellationToken, Func action) - { - var fileLock = _perFileProcessingLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); - await fileLock.WaitAsync(cancellationToken); - try - { - await action(); - } - finally - { - fileLock.Release(); - } - } - - private void EnsureWatcherHandlersBound() - { - if (_watcherCreatedHandler != null) - { - return; - } - -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info(">>> GitChangeObserverCore: Binding file watcher events"); -#endif - _watcherCreatedHandler += (sender, e) => - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: File created event enqueued: '{e.FullPath}'"); -#endif - if (ShouldEnqueueEvent(e)) - { - _eventProcessor?.EnqueueEvent(new FileChangeEvent(FileChangeType.Create, e.FullPath)); - } - }; - _watcherChangedHandler += (sender, e) => - { - if (ShouldEnqueueEvent(e)) - { - _eventProcessor?.EnqueueEvent(new FileChangeEvent(FileChangeType.Change, e.FullPath)); - } - }; - _watcherDeletedHandler += (sender, e) => - { -#if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> GitChangeObserverCore: File deleted event enqueued: '{e.FullPath}'"); -#endif - if (ShouldEnqueueEvent(e)) - { - _eventProcessor?.EnqueueEvent(new FileChangeEvent(FileChangeType.Delete, e.FullPath)); - } - }; - } - - private void BindWatcherEvents(FileSystemWatcher watcher) - { - EnsureWatcherHandlersBound(); - watcher.Created += _watcherCreatedHandler; - watcher.Changed += _watcherChangedHandler; - watcher.Deleted += _watcherDeletedHandler; - } - - private bool ShouldEnqueueEvent(FileSystemEventArgs e) - { - if (_gitService.IsFileIgnored(e.FullPath) || !Path.HasExtension(e.FullPath)) - { - return false; - } - - return GitPathHelper.IsPathUnderAnyRoot(e.FullPath, _workspacePaths); - } - - private async Task ProcessEventAsync(FileChangeEvent evt, List changedFiles, long? operationGeneration, CancellationToken cancellationToken, string baselineCommit) - { - if (evt.Type == FileChangeType.Delete) - { - await _fileChangeHandler.HandleFileDeleteAsync(evt.FilePath, changedFiles, cancellationToken); - return; - } - - await ExecutePerFileAsync( - evt.FilePath, - cancellationToken, - () => _fileChangeHandler.HandleFileChangeAsync(evt.FilePath, changedFiles, operationGeneration, cancellationToken, baselineCommit)); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitIgnoreWatcher.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitIgnoreWatcher.cs deleted file mode 100644 index 1f1f67e3..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitIgnoreWatcher.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.IO; -using Codescene.VSExtension.Core.Interfaces; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class GitIgnoreWatcher : IDisposable - { - private readonly ILogger _logger; - private FileSystemWatcher _watcher; - private bool _disposed; - - public GitIgnoreWatcher(string gitRootPath, ILogger logger) - { - _logger = logger; - if (string.IsNullOrEmpty(gitRootPath) || !Directory.Exists(gitRootPath)) - { - return; - } - - try - { - _watcher = new FileSystemWatcher(gitRootPath) - { - Filter = ".gitignore", - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime, - IncludeSubdirectories = true, - }; - _watcher.Created += OnGitIgnoreEvent; - _watcher.Changed += OnGitIgnoreEvent; - _watcher.Deleted += OnGitIgnoreEvent; - _watcher.EnableRaisingEvents = true; - } - catch (Exception ex) - { - _logger?.Error($"GitIgnoreWatcher: Could not create watcher for {gitRootPath}", ex); - } - } - - public event EventHandler GitIgnoreChanged; - - public void Dispose() - { - if (_disposed) - { - return; - } - - if (_watcher != null) - { - try - { - _watcher.EnableRaisingEvents = false; - _watcher.Created -= OnGitIgnoreEvent; - _watcher.Changed -= OnGitIgnoreEvent; - _watcher.Deleted -= OnGitIgnoreEvent; - _watcher.Dispose(); - } - catch - { - } - - _watcher = null; - } - - _disposed = true; - GC.SuppressFinalize(this); - } - - private void OnGitIgnoreEvent(object sender, FileSystemEventArgs e) - { - try - { - GitIgnoreChanged?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - _logger?.Error("GitIgnoreWatcher: Error in GitIgnoreChanged handler", ex); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchMergeBaseSelector.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchMergeBaseSelector.cs deleted file mode 100644 index bb97e81f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchMergeBaseSelector.cs +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using Codescene.VSExtension.Core.Interfaces; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public static class MainBranchMergeBaseSelector - { - /// - /// Returns the merge-base between HEAD and the closest known main branch (local or remote-tracking). - /// When multiple main branches yield distinct merge bases, the one that is first reachable walking - /// back from HEAD is chosen so a feature branched off develop is not baselined against main. - /// - public static Commit FindClosest(Repository repo, ILogger logger = null) - { - return Select(repo, logger).Commit; - } - - public static MergeBaseSelection Select(Repository repo, ILogger logger = null) - { - if (repo?.Head?.Tip == null) - { - return default; - } - - var currentBranchName = repo.Head.FriendlyName; - var candidates = CollectCandidates(repo, currentBranchName, logger); - - if (candidates.MergeBases.Count == 0) - { - return default; - } - - var closestSha = FindClosestReachableSha(repo, candidates.MergeBases); - if (closestSha == null) - { - closestSha = candidates.MergeBases.Keys.First(); - } - - candidates.MergeBases.TryGetValue(closestSha, out var commit); - candidates.BaselineBranches.TryGetValue(closestSha, out var baselineBranchName); - return new MergeBaseSelection(commit, baselineBranchName); - } - - private static CandidateCollection CollectCandidates(Repository repo, string currentBranch, ILogger logger) - { - var candidates = new CandidateCollection(); - - var defaultBranch = MainBranchNames.GetDefaultBranch(repo); - if (!string.IsNullOrEmpty(defaultBranch)) - { - TryAddCandidate(repo, currentBranch, defaultBranch, candidates, logger); - return candidates; - } - - foreach (var mainBranchName in MainBranchNames.All) - { - TryAddCandidate(repo, currentBranch, mainBranchName, candidates, logger); - } - - return candidates; - } - - private static void TryAddCandidate( - Repository repo, - string currentBranch, - string mainBranchName, - CandidateCollection candidates, - ILogger logger) - { - var mainBranch = repo.Branches[mainBranchName] - ?? repo.Branches[$"origin/{mainBranchName}"]; - - if (mainBranch?.Tip == null || - string.Equals(mainBranch.FriendlyName, currentBranch, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - try - { - var mergeBase = repo.ObjectDatabase.FindMergeBase(repo.Head.Tip, mainBranch.Tip); - if (mergeBase != null) - { - candidates.MergeBases[mergeBase.Sha] = mergeBase; - if (!candidates.BaselineBranches.ContainsKey(mergeBase.Sha)) - { - candidates.BaselineBranches[mergeBase.Sha] = mainBranchName; - } - } - } - catch (Exception e) - { - logger?.Debug($"Could not find merge-base with {mainBranchName}: {e.Message}"); - } - } - - private static string FindClosestReachableSha(Repository repo, IReadOnlyDictionary mergeBases) - { - foreach (var commit in repo.Commits.QueryBy(new CommitFilter - { - IncludeReachableFrom = repo.Head, - SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, - })) - { - if (mergeBases.ContainsKey(commit.Sha)) - { - return commit.Sha; - } - } - - return null; - } - - private sealed class CandidateCollection - { - public Dictionary MergeBases { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public Dictionary BaselineBranches { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchNames.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchNames.cs deleted file mode 100644 index 0f432228..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchNames.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public static class MainBranchNames - { - public static readonly IReadOnlyList All = new[] - { - "main", "master", "develop", "development", "trunk", "dev", - }; - - private const string OriginHeadRef = "refs/remotes/origin/HEAD"; - private const string OriginRemotePrefix = "refs/remotes/origin/"; - - private static readonly Dictionary _defaultBranchCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - private static readonly object _cacheLock = new object(); - - public static string GetDefaultBranch(Repository repo) - { - try - { - var gitRoot = repo?.Info?.WorkingDirectory; - if (string.IsNullOrEmpty(gitRoot)) - { - return GetDefaultBranchUncached(repo); - } - - var normalizedRoot = gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - lock (_cacheLock) - { - if (_defaultBranchCache.TryGetValue(normalizedRoot, out var cached)) - { - return cached; - } - } - - var result = GetDefaultBranchUncached(repo); - - if (result != null) - { - lock (_cacheLock) - { - _defaultBranchCache[normalizedRoot] = result; - } - } - - return result; - } - catch - { - return null; - } - } - - public static void ClearDefaultBranchCache(string gitRootPath = null) - { - lock (_cacheLock) - { - if (string.IsNullOrEmpty(gitRootPath)) - { - _defaultBranchCache.Clear(); - return; - } - - var normalizedRoot = gitRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - _defaultBranchCache.Remove(normalizedRoot); - } - } - - public static bool IsMainBranch(Repository repo, string branchName) - { - if (string.IsNullOrEmpty(branchName)) - { - return false; - } - - var defaultBranch = GetDefaultBranch(repo); - if (!string.IsNullOrEmpty(defaultBranch)) - { - return string.Equals(branchName, defaultBranch, StringComparison.OrdinalIgnoreCase); - } - - return All.Contains(branchName, StringComparer.OrdinalIgnoreCase); - } - - public static bool IsMainBranch(string branchName) - { - return !string.IsNullOrEmpty(branchName) - && All.Contains(branchName, StringComparer.OrdinalIgnoreCase); - } - - private static string GetDefaultBranchUncached(Repository repo) - { - var gitRoot = repo?.Info?.WorkingDirectory; - if (!string.IsNullOrEmpty(gitRoot)) - { - var configured = CodesceneRepoConfig.GetBaselineBranch(gitRoot); - if (!string.IsNullOrEmpty(configured)) - { - return configured; - } - } - - var originHead = repo?.Refs[OriginHeadRef]; - if (originHead == null) - { - return null; - } - - var target = originHead.TargetIdentifier; - if (string.IsNullOrEmpty(target)) - { - return null; - } - - if (target.StartsWith(OriginRemotePrefix, StringComparison.Ordinal)) - { - return target.Substring(OriginRemotePrefix.Length); - } - - return null; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MergeBaseFinder.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MergeBaseFinder.cs deleted file mode 100644 index 000b6f2d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MergeBaseFinder.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using Codescene.VSExtension.Core.Interfaces; -using LibGit2Sharp; - -namespace Codescene.VSExtension.Core.Application.Git -{ - internal class MergeBaseFinder - { - private readonly ILogger _logger; - - public MergeBaseFinder(ILogger logger) - { - _logger = logger; - } - - public Commit GetMergeBaseCommit(Repository repo) - { - try - { - if (repo?.Head?.Tip == null) - { - return null; - } - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> MergeBaseFinder: Finding merge base for branch '{repo.Head.FriendlyName}'"); - #endif - - var selection = MainBranchMergeBaseSelector.Select(repo, _logger); - var mergeBase = selection.Commit; - if (mergeBase != null) - { - var branchLabel = string.IsNullOrEmpty(selection.BaselineBranchName) - ? "closest main branch" - : selection.BaselineBranchName; - _logger?.Debug($"GitChangeLister: Found merge base using branch '{branchLabel}' ({mergeBase.Sha})"); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> MergeBaseFinder: Found merge base commit {mergeBase.Sha.Substring(0, 8)} for branch '{repo.Head.FriendlyName}'"); - #endif - return mergeBase; - } - - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> MergeBaseFinder: No merge base found for branch '{repo.Head.FriendlyName}'"); - #endif - return null; - } - catch (Exception ex) - { - _logger?.Debug($"GitChangeLister: Could not determine merge base: {ex.Message}"); - return null; - } - } - - public bool IsMainBranch(string branchName) - { - return MainBranchNames.IsMainBranch(branchName); - } - - public bool IsMainBranch(Repository repo, string branchName) - { - return MainBranchNames.IsMainBranch(repo, branchName); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/SavedFilesTrackerCore.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/SavedFilesTrackerCore.cs deleted file mode 100644 index 599c358d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/SavedFilesTrackerCore.cs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Git; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class SavedFilesTrackerCore : ISavedFilesTracker, IDisposable - { - private readonly HashSet _savedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); - private readonly object _lock = new object(); - private readonly IOpenFilesObserver _openFilesObserver; - private readonly ILogger _logger; - private readonly IDocumentSaveEventSource _eventSource; - private bool _disposed; - - public SavedFilesTrackerCore( - IDocumentSaveEventSource eventSource, - IOpenFilesObserver openFilesObserver, - ILogger logger) - { - _eventSource = eventSource ?? throw new ArgumentNullException(nameof(eventSource)); - _openFilesObserver = openFilesObserver ?? throw new ArgumentNullException(nameof(openFilesObserver)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - _eventSource.DocumentSaved += OnDocumentSaved; - } - - public IEnumerable GetSavedFiles() - { - lock (_lock) - { - return _savedFiles.ToList(); - } - } - - public void ClearSavedFiles() - { - lock (_lock) - { - _savedFiles.Clear(); - } - } - - public void RemoveFromTracker(string filePath) - { - if (string.IsNullOrEmpty(filePath)) - { - return; - } - - lock (_lock) - { - _savedFiles.Remove(filePath); - } - } - - public void Start() - { - _eventSource.Start(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _eventSource.DocumentSaved -= OnDocumentSaved; - _eventSource.Dispose(); - } - - _disposed = true; - } - - private void OnDocumentSaved(object sender, string filePath) - { - if (string.IsNullOrEmpty(filePath)) - { - return; - } - - var visibleFiles = _openFilesObserver.GetAllVisibleFileNames(); - if (visibleFiles == null) - { - return; - } - - var isVisible = visibleFiles.Any(f => - string.Equals(f, filePath, StringComparison.OrdinalIgnoreCase)); - - if (!isVisible) - { - _logger.Debug($"SavedFilesTracker: Ignoring save for non-visible file: {filePath}"); - return; - } - - lock (_lock) - { - _savedFiles.Add(filePath); - } - - _logger.Debug($"SavedFilesTracker: Tracked save for: {filePath}"); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/TrackerManager.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/TrackerManager.cs deleted file mode 100644 index 790d5f08..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/TrackerManager.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using Codescene.VSExtension.Core.Interfaces; - -namespace Codescene.VSExtension.Core.Application.Git -{ - public class TrackerManager - { - private readonly HashSet _tracker = new HashSet(); - private readonly object _lock = new object(); - private readonly ILogger _logger; - - public TrackerManager(ILogger logger = null) - { - _logger = logger; - } - - public void Add(string filePath) - { - lock (_lock) - { - _tracker.Add(filePath); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> TrackerManager: Added file to tracker: {filePath} (total: {_tracker.Count})"); - #endif - } - } - - public bool Contains(string filePath) - { - lock (_lock) - { - return _tracker.Contains(filePath); - } - } - - public bool Remove(string filePath) - { - lock (_lock) - { - var removed = _tracker.Remove(filePath); - if (removed) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> TrackerManager: Removed file from tracker: {filePath} (total: {_tracker.Count})"); - #endif - } - - return removed; - } - } - - public List GetFilesStartingWith(string prefix) - { - lock (_lock) - { - var matches = _tracker.Where(tf => tf.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)).ToList(); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> TrackerManager: GetFilesStartingWith prefix '{prefix}' found {matches.Count} matches"); - #endif - return matches; - } - } - - public List GetAllTrackedFiles() - { - lock (_lock) - { - return _tracker.ToList(); - } - } - - public void RemoveAll(List filesToRemove) - { - lock (_lock) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> TrackerManager: Removing {filesToRemove.Count} files from tracker"); - #endif - foreach (var file in filesToRemove) - { - _tracker.Remove(file); - } - } - } - - public void Clear() - { - lock (_lock) - { - var count = _tracker.Count; - _tracker.Clear(); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> TrackerManager: Cleared all tracked files (was tracking {count} files)"); - #endif - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/UntrackedFileProcessor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/UntrackedFileProcessor.cs deleted file mode 100644 index 49c27836..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/UntrackedFileProcessor.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Codescene.VSExtension.Core.Interfaces; - -namespace Codescene.VSExtension.Core.Application.Git -{ - internal class UntrackedFileProcessor - { - private const int MaxUntrackedFilesPerLocation = 5; - private readonly ILogger _logger; - - public UntrackedFileProcessor(ILogger logger = null) - { - _logger = logger; - } - - public void AddUntrackedFileToDirectory( - string relativePath, - string absolutePath, - Dictionary> untrackedByDirectory) - { - var directory = string.IsNullOrEmpty(Path.GetDirectoryName(relativePath)) ? "__root__" : Path.GetDirectoryName(relativePath); - - if (!untrackedByDirectory.ContainsKey(directory)) - { - untrackedByDirectory[directory] = new List(); - } - - untrackedByDirectory[directory].Add(absolutePath); - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> UntrackedFileProcessor: Added untracked file '{absolutePath}' to directory tracking for '{directory}'"); - #endif - } - - public void ProcessUntrackedDirectories( - Dictionary> untrackedByDirectory, - HashSet savedFiles, - HashSet changedFiles) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> UntrackedFileProcessor: Processing {untrackedByDirectory.Count} directories with untracked files"); - #endif - foreach (var kvp in untrackedByDirectory) - { - var directory = kvp.Key; - var files = kvp.Value; - - if (files.Count > MaxUntrackedFilesPerLocation) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> UntrackedFileProcessor: Directory '{directory}' exceeds threshold with {files.Count} untracked files (max: {MaxUntrackedFilesPerLocation})"); - #endif - } - - var filesToAdd = files.Count > MaxUntrackedFilesPerLocation - ? files.Where(f => savedFiles.Contains(f)) - : files; - - var addedCount = filesToAdd.Count(); - if (addedCount > 0) - { - #if FEATURE_INITIAL_GIT_OBSERVER - _logger?.Info($">>> UntrackedFileProcessor: Adding {addedCount} files from directory '{directory}'"); - #endif - } - - changedFiles.UnionWith(filesToAdd); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs new file mode 100644 index 00000000..7f04a7d5 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs @@ -0,0 +1,259 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Application.Git +{ + public sealed partial class WorkspaceWatchCoordinator + { + internal async Task AdmitsDeltaAsync(string reportedRoot, string relPath) + { + if (_disposed || !_enabled) + { + return true; + } + + string repoRoot; + string posixPath; + string key; + if (AlreadyAdmitted(reportedRoot, relPath, out repoRoot, out posixPath, out key)) + { + return true; + } + + if (!TryScheduleRefresh(repoRoot, posixPath, key, out var refresh)) + { + return true; + } + + await refresh.ConfigureAwait(false); + if (AdmittedAfterRefresh(repoRoot, posixPath)) + { + return true; + } + + _logger?.Debug("[watch] delta rejected " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("path", posixPath), + LogFields.Field("reason", "not-in-inventory"))); + return false; + } + + private bool AlreadyAdmitted(string reportedRoot, string relPath, out string repoRoot, out string posixPath, out string key) + { + var missingInventory = false; + lock (_gate) + { + repoRoot = ResolveRepoRoot(reportedRoot); + posixPath = RpcPath.ToPosixRelPath(relPath); + key = RpcPath.NormalizeFsPath(repoRoot); + if (!_inventories.ContainsKey(key)) + { + missingInventory = true; + } + else if (InventoryContainsUnlocked(repoRoot, posixPath)) + { + return true; + } + } + + if (missingInventory) + { + _logger?.Debug("[watch] delta admitted " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("path", posixPath), + LogFields.Field("reason", "no-inventory"))); + return true; + } + + return IsDirty(repoRoot, posixPath); + } + + private bool TryScheduleRefresh(string repoRoot, string posixPath, string key, out Task refresh) + { + lock (_gate) + { + if (InventoryContainsUnlocked(repoRoot, posixPath)) + { + refresh = Task.CompletedTask; + return false; + } + + _logger?.Debug("[watch] delta reconcile scheduled " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("path", posixPath), + LogFields.Field("reason", "not-in-inventory"))); + refresh = ScheduleInventoryRefreshUnlocked(repoRoot, key); + return true; + } + } + + private bool AdmittedAfterRefresh(string repoRoot, string posixPath) + { + var inInventory = false; + lock (_gate) + { + inInventory = _enabled && InventoryContainsUnlocked(repoRoot, posixPath); + } + + return inInventory || IsDirty(repoRoot, posixPath); + } + + private void OnQueueChanged(object sender, ReviewQueue queue) + { + lock (_gate) + { + _queueIdleTimer?.Dispose(); + _queueIdleTimer = null; + if (!ShouldPruneWhenIdle(queue)) + { + return; + } + + _queueIdleTimer = new Timer( + _ => + { + var prune = false; + lock (_gate) + { + _queueIdleTimer?.Dispose(); + _queueIdleTimer = null; + prune = !_disposed && _enabled; + } + + if (prune) + { + PruneMonitor(); + _logger?.Debug("[watch] queue idle, monitor re-pruned"); + } + }, + null, + QueueIdlePruneDelayMs, + Timeout.Infinite); + } + } + + private bool ShouldPruneWhenIdle(ReviewQueue queue) + { + if (_disposed || !_enabled) + { + return false; + } + + if (queue == null || queue.Count > 0) + { + return false; + } + + return _inventories.Count > 0; + } + + private void PauseWatching() + { + lock (_gate) + { + CancelPendingRefreshesUnlocked(); + foreach (var normalizedRoot in _watched.Keys.ToList()) + { + var repoRoot = _knownRoots.TryGetValue(normalizedRoot, out var known) ? known : normalizedRoot; + _logger?.Info("[watch] stopped " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "automatic-analysis-stopped"))); + _client.StopWatchFiles(repoRoot); + } + + _watched.Clear(); + PublishActiveReposUnlocked(); + } + + _host?.PruneMonitor(Array.Empty(), new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private bool InventoryContainsUnlocked(string repoRoot, string relPath) + { + var key = RpcPath.NormalizeFsPath(repoRoot); + return _inventories.TryGetValue(key, out var inventory) && inventory.Contains(relPath); + } + + private Task ScheduleInventoryRefreshUnlocked(string repoRoot, string key) + { + if (_pendingRefreshes.TryGetValue(key, out var existing)) + { + existing.Timer.Change(InventoryRefreshDelayMs, Timeout.Infinite); + return existing.Settled.Task; + } + + var pending = new PendingRefresh(); + pending.Timer = new Timer( + _ => + { + lock (_gate) + { + if (_pendingRefreshes.TryGetValue(key, out var current)) + { + current.Timer.Dispose(); + _pendingRefreshes.Remove(key); + } + } + + _ = RefreshThenSettleAsync(repoRoot, pending); + }, + null, + InventoryRefreshDelayMs, + Timeout.Infinite); + _pendingRefreshes[key] = pending; + return pending.Settled.Task; + } + + private async Task RefreshThenSettleAsync(string repoRoot, PendingRefresh pending) + { + await RefreshInventoryAsync(repoRoot).ConfigureAwait(false); + pending.Settled.TrySetResult(true); + } + + private async Task RefreshInventoryAsync(string repoRoot) + { + _logger?.Debug("[watch] inventory refresh requested " + LogFields.Format(LogFields.Field("repo", repoRoot))); + try + { + var inventory = await _client.GetWatchInventoryAsync(repoRoot).ConfigureAwait(false); + lock (_gate) + { + ApplyInventoryUnlocked(repoRoot, inventory?.Files ?? Array.Empty()); + } + } + catch (Exception ex) + { + _logger?.Debug("[watch] inventory refresh skipped " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("error", ex.Message))); + } + } + + private void CancelPendingRefreshesUnlocked() + { + foreach (var pending in _pendingRefreshes.Values) + { + pending.Timer.Dispose(); + pending.Settled.TrySetResult(false); + } + + _pendingRefreshes.Clear(); + _queueIdleTimer?.Dispose(); + _queueIdleTimer = null; + } + + private sealed class PendingRefresh + { + public Timer Timer { get; set; } + + public TaskCompletionSource Settled { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs new file mode 100644 index 00000000..818aa6ca --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -0,0 +1,562 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Application.Cache.Review; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; + +namespace Codescene.VSExtension.Core.Application.Git +{ + [Export(typeof(IWorkspaceWatchCoordinator))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed partial class WorkspaceWatchCoordinator : IWorkspaceWatchCoordinator, IDisposable + { + internal const int InventoryRefreshDelayMs = 250; + internal const int QueueIdlePruneDelayMs = 1000; + private readonly IIdeServerClient _client; + private readonly IReviewPipeline _pipeline; + private readonly ILogger _logger; + private readonly IWorkspaceWatchHost _host; + private readonly IGitService _gitService; + private readonly object _gate = new object(); + private readonly Dictionary _watched = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _inventories = new Dictionary>(StringComparer.Ordinal); + private readonly Dictionary _pendingRefreshes = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _knownRoots = new Dictionary(StringComparer.Ordinal); + private IReadOnlyList _workspaceDirectories = Array.Empty(); + private long _watchSession; + private Timer _queueIdleTimer; + private volatile bool _enabled = true; + private bool _disposed; + + [ImportingConstructor] + public WorkspaceWatchCoordinator( + IIdeServerClient client, + IReviewPipeline pipeline, + ILogger logger, + [Import(AllowDefault = true)] IWorkspaceWatchHost host = null, + [Import(AllowDefault = true)] ISettingsProvider settings = null, + [Import(AllowDefault = true)] IGitService gitService = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + _logger = logger; + _host = host; + _gitService = gitService; + _enabled = settings?.EnableAutomaticAnalysis ?? true; + _pipeline.SetWatchDeltaFilter((repoRoot, relPath) => AdmitsDeltaAsync(repoRoot, relPath)); + _client.WatchInventoryChanged += OnWatchInventoryChanged; + _client.ServerStarted += OnServerStarted; + _client.QueueChanged += OnQueueChanged; + } + + public long WatchSession + { + get + { + lock (_gate) + { + return _watchSession; + } + } + } + + public Task SyncAsync(IReadOnlyList workspaceDirectories, long watchSession) + { + return ApplySync(workspaceDirectories, watchSession); + } + + public Task SyncAsync(IReadOnlyList workspaceDirectories) + { + return ApplySync(workspaceDirectories, null); + } + + public void SetEnabled(bool enabled) + { + if (_disposed || _enabled == enabled) + { + return; + } + + _enabled = enabled; + if (enabled) + { + _logger?.Info("[watch] automatic analysis started"); + _ = SyncAsync(_workspaceDirectories); + return; + } + + _logger?.Info("[watch] automatic analysis stopped"); + PauseWatching(); + } + + public void StopAll() + { + lock (_gate) + { + _watchSession++; + _workspaceDirectories = Array.Empty(); + foreach (var repoRoot in _watched.Keys.ToList()) + { + StopWatchingUnlocked(repoRoot); + } + + _inventories.Clear(); + _knownRoots.Clear(); + CancelPendingRefreshesUnlocked(); + _pipeline.SetActiveRepos(Array.Empty()); + } + + _pipeline.Reset(); + _host?.PruneMonitor(Array.Empty(), new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + public IReadOnlyCollection GetInventoryFiles(string repoRoot) + { + lock (_gate) + { + if (!_inventories.TryGetValue(RpcPath.NormalizeFsPath(repoRoot), out var files)) + { + return Array.Empty(); + } + + return files.ToList(); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _client.WatchInventoryChanged -= OnWatchInventoryChanged; + _client.ServerStarted -= OnServerStarted; + _client.QueueChanged -= OnQueueChanged; + StopAll(); + } + + private static List ResolveTargets(IReadOnlyList directories) + { + var targets = new Dictionary(StringComparer.Ordinal); + foreach (var directory in directories) + { + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(directory); + if (string.IsNullOrEmpty(repoRoot)) + { + foreach (var nested in GitPathDiscovery.FindNestedRepositories(directory)) + { + targets[RpcPath.NormalizeFsPath(nested)] = nested; + } + + continue; + } + + targets[RpcPath.NormalizeFsPath(repoRoot)] = repoRoot; + } + + return targets.Values.ToList(); + } + + private static IReadOnlyList ScopePaths(string repoRoot, IReadOnlyList workspaceDirectories) + { + var relative = new List(); + foreach (var directory in workspaceDirectories ?? Array.Empty()) + { + if (!RpcPath.PathsEqual(GitPathDiscovery.TryGetWorkingDirectory(directory) ?? string.Empty, repoRoot)) + { + continue; + } + + var posix = RpcPath.RelativePosix(repoRoot, directory); + if (!IsNestedScope(posix)) + { + return null; + } + + relative.Add(posix); + } + + return CoversEntireRepo(relative) ? null : relative; + } + + private static bool IsNestedScope(string posix) + { + if (string.IsNullOrEmpty(posix) || posix == ".") + { + return false; + } + + return !posix.StartsWith("..", StringComparison.Ordinal); + } + + private static bool CoversEntireRepo(IReadOnlyList relative) + { + return relative.Count == 0 || relative.Any(path => string.IsNullOrEmpty(path) || path == "."); + } + + private Task ApplySync(IReadOnlyList workspaceDirectories, long? watchSession) + { + if (_disposed) + { + return Task.CompletedTask; + } + + var directories = workspaceDirectories ?? Array.Empty(); + if (!SessionMatches(watchSession)) + { + return Task.CompletedTask; + } + + if (!_enabled) + { + RememberDirectories(directories, watchSession); + return Task.CompletedTask; + } + + var targets = ResolveTargets(directories); + _logger?.Debug("[watch] syncing " + LogFields.Format(LogFields.Field("count", targets.Count))); + if (ReplaceWatchedRoots(targets, watchSession)) + { + PruneMonitor(); + } + + foreach (var target in targets) + { + EnsureWatch(target, ScopePaths(target, directories), watchSession); + } + + RememberDirectories(directories, watchSession); + return Task.CompletedTask; + } + + private void RememberDirectories(IReadOnlyList directories, long? watchSession) + { + lock (_gate) + { + if (!watchSession.HasValue || watchSession.Value == _watchSession) + { + _workspaceDirectories = directories; + } + } + } + + private bool SessionMatches(long? watchSession) + { + lock (_gate) + { + return !watchSession.HasValue || watchSession.Value == _watchSession; + } + } + + private bool ReplaceWatchedRoots(IReadOnlyList targets, long? watchSession) + { + var stopped = false; + lock (_gate) + { + if (!SessionMatches(watchSession)) + { + return false; + } + + _knownRoots.Clear(); + foreach (var target in targets) + { + RpcPath.RememberLocalRepoRoot(target); + _knownRoots[RpcPath.NormalizeFsPath(target)] = target; + } + + var keep = new HashSet(targets.Select(RpcPath.NormalizeFsPath), StringComparer.Ordinal); + foreach (var existing in _watched.Keys.ToList()) + { + if (!keep.Contains(existing)) + { + StopWatchingUnlocked(existing); + stopped = true; + } + } + } + + return stopped; + } + + private void EnsureWatch(string repoRoot, IReadOnlyList relativePaths, long? watchSession) + { + var key = RpcPath.NormalizeFsPath(repoRoot); + var scopeKey = relativePaths == null ? "*" : string.Join("|", relativePaths); + var replacedScope = false; + lock (_gate) + { + if (!SessionMatches(watchSession)) + { + return; + } + + if (_watched.TryGetValue(key, out var existingScope) && existingScope == scopeKey) + { + _logger?.Debug("[watch] ensureWatch " + LogFields.Format( + LogFields.Field("action", "skip"), + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "scope-unchanged"), + LogFields.Field("scopeKey", scopeKey))); + } + else + { + if (_watched.ContainsKey(key)) + { + replacedScope = true; + _logger?.Info("[watch] stopped " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "scope-changed"))); + _client.StopWatchFiles(repoRoot); + ApplyInventoryUnlocked(repoRoot, Array.Empty()); + } + + _logger?.Info("[watch] ensureWatch " + LogFields.Format( + LogFields.Field("action", "startWatch"), + LogFields.Field("repo", repoRoot), + LogFields.Field("paths", relativePaths ?? (IReadOnlyList)new[] { "(whole repository)" }))); + _client.WatchFiles(repoRoot, relativePaths); + _watched[key] = scopeKey; + PublishActiveReposUnlocked(); + } + } + + if (replacedScope) + { + PruneMonitor(); + } + + Seed(repoRoot); + } + + private void StopWatchingUnlocked(string normalizedRoot) + { + if (!_watched.TryGetValue(normalizedRoot, out var _)) + { + return; + } + + var repoRoot = _knownRoots.TryGetValue(normalizedRoot, out var known) ? known : normalizedRoot; + _logger?.Info("[watch] stopped " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("reason", "unspecified"))); + _client.StopWatchFiles(repoRoot); + _watched.Remove(normalizedRoot); + PublishActiveReposUnlocked(); + ApplyInventoryUnlocked(repoRoot, Array.Empty()); + } + + private void OnWatchInventoryChanged(object sender, WatchInventory inventory) + { + if (inventory == null || !_enabled) + { + return; + } + + var applied = false; + lock (_gate) + { + var key = RpcPath.NormalizeFsPath(ResolveRepoRoot(inventory.RepoRoot)); + if (!_watched.ContainsKey(key)) + { + return; + } + + applied = ApplyInventoryUnlocked(inventory.RepoRoot, inventory.Files ?? Array.Empty()); + } + + if (applied) + { + PruneMonitor(); + } + } + + private bool ApplyInventoryUnlocked(string repoRoot, IReadOnlyList files) + { + if (!_enabled) + { + return false; + } + + var resolved = ResolveRepoRoot(repoRoot); + var key = RpcPath.NormalizeFsPath(resolved); + var relPaths = new HashSet(files.Select(RpcPath.ToPosixRelPath), StringComparer.Ordinal); + _inventories[key] = relPaths; + _logger?.Debug("[watch] inventory applied " + LogFields.Format( + LogFields.Field("repo", resolved), + LogFields.Field("files", relPaths.Count))); + return true; + } + + private void PruneMonitor() + { + IReadOnlyList repoRoots; + ISet keep; + lock (_gate) + { + if (_disposed) + { + return; + } + + repoRoots = _inventories.Keys.Select(ResolveRepoRoot).ToList(); + keep = PathsToKeepUnlocked(); + } + + foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) + { + if (!IsGitIgnoredDocument(document)) + { + keep.Add(document.FilePath); + } + } + + _host?.PruneMonitor(repoRoots, keep); + } + + private void OnServerStarted(object sender, ServerStartEvent startEvent) + { + if (!ShouldRestoreWatches(startEvent)) + { + return; + } + + _logger?.Info("[watch] cs-ide restarted, re-establishing repository watches"); + lock (_gate) + { + _watched.Clear(); + } + + _ = SyncAsync(_knownRoots.Values.ToList()); + } + + private bool ShouldRestoreWatches(ServerStartEvent startEvent) + { + if (startEvent == null || _disposed) + { + return false; + } + + if (!_enabled) + { + return false; + } + + return startEvent.Restart; + } + + private void Seed(string repoRoot) + { + if (!_enabled) + { + return; + } + + var submissions = DirtySubmissions(repoRoot); + if (submissions.Count == 0) + { + return; + } + + _logger?.Info($"[watch] seeding reviewFiles count={submissions.Count} repo={repoRoot}"); + _ = _pipeline.SubmitBatchAsync(repoRoot, submissions.ToArray()); + } + + private List DirtySubmissions(string repoRoot) + { + var submissions = new List(); + foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) + { + if (!RpcPath.PathsEqual(GitPathDiscovery.TryGetWorkingDirectory(document.FilePath) ?? string.Empty, repoRoot)) + { + continue; + } + + if (IsGitIgnoredDocument(document)) + { + continue; + } + + submissions.Add(new ReviewSubmission + { + Document = document, + RelPath = RpcPath.RelativePosix(repoRoot, document.FilePath), + Content = document.Content, + UpdateDiagnosticsPane = true, + UpdateMonitor = true, + }); + } + + return submissions; + } + + private bool IsDirty(string repoRoot, string relPath) + { + foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) + { + if (RpcPath.ToPosixRelPath(RpcPath.RelativePosix(repoRoot, document.FilePath)) == relPath) + { + return !IsGitIgnoredDocument(document); + } + } + + return false; + } + + private bool IsGitIgnoredDocument(ReviewDocument document) + { + return _gitService != null + && !string.IsNullOrEmpty(document?.FilePath) + && _gitService.IsFileIgnored(document.FilePath); + } + + private ISet PathsToKeepUnlocked() + { + var keep = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pair in _inventories) + { + var repoRoot = ResolveRepoRoot(pair.Key); + foreach (var relPath in pair.Value) + { + keep.Add(RpcPath.CombineRepo(repoRoot, relPath)); + } + } + + return keep; + } + + private void PublishActiveReposUnlocked() + { + _pipeline.SetActiveRepos(_watched.Keys.Select(ResolveRepoRoot).ToList()); + } + + private string ResolveRepoRoot(string reported) + { + if (_knownRoots.TryGetValue(RpcPath.NormalizeFsPath(reported), out var known)) + { + return known; + } + + var local = RpcPath.ToLocalRepoRoot(reported); + if (!string.Equals(local, reported, StringComparison.Ordinal)) + { + return local; + } + + return Path.GetFullPath(reported ?? string.Empty); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Mappers/CodeHealthMonitorMapper.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Mappers/CodeHealthMonitorMapper.cs index 2d92cfd7..038a9fe8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Mappers/CodeHealthMonitorMapper.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Mappers/CodeHealthMonitorMapper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.Composition; using System.Linq; +using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Ace; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; @@ -16,11 +17,15 @@ namespace Codescene.VSExtension.Core.Application.Mappers public class CodeHealthMonitorMapper { private readonly IPreflightManager _preflightManager; + private readonly ICodeHealthMonitorNotifier _notifier; [ImportingConstructor] - public CodeHealthMonitorMapper(IPreflightManager preflightManager) + public CodeHealthMonitorMapper( + IPreflightManager preflightManager, + [Import(AllowDefault = true)] ICodeHealthMonitorNotifier notifier = null) { _preflightManager = preflightManager; + _notifier = notifier; } public CodeHealthMonitorComponentData Map(Dictionary fileDeltas) @@ -34,7 +39,7 @@ public CodeHealthMonitorComponentData Map(Dictionary Delta = new Delta { ScoreChange = pair.Value.ScoreChange, - OldScore = pair.Value.OldScore, + OldScore = pair.Value.OldScore ?? 0m, NewScore = pair.Value.NewScore, FileLevelFindings = ToChangeDetails(pair.Value.FileLevelFindings), FunctionLevelFindings = ToFunctionFinding(pair.Value.FunctionLevelFindings), @@ -46,6 +51,9 @@ public CodeHealthMonitorComponentData Map(Dictionary AutoRefactor = _preflightManager.GetAutoRefactorConfig(), FileDeltaData = files, Jobs = DeltaJobTracker.RunningJobs.ToList(), + AnalysisState = _notifier?.AnalysisState, + TotalCount = _notifier?.TotalCount, + RemainingCount = _notifier?.RemainingCount ?? 0, }; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs index f2d81ba1..f4d90cdf 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -2,25 +2,67 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.Composition; using Codescene.VSExtension.Core.Consts; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Models; +using Codescene.VSExtension.Core.Models.Cli.Rpc; using Codescene.VSExtension.Core.Models.WebComponent.Data; using Codescene.VSExtension.Core.Util; namespace Codescene.VSExtension.Core.Application.Services { + [Export(typeof(ICodeHealthMonitorNotifier))] + [Export(typeof(CodeHealthMonitorNotifier))] + [PartCreationPolicy(CreationPolicy.Shared)] public class CodeHealthMonitorNotifier : ICodeHealthMonitorNotifier { private readonly ConcurrentDictionary _activeJobs = new ConcurrentDictionary(); private readonly object _lock = new object(); + private bool _automaticAnalysisEnabled = true; public event EventHandler ViewUpdateRequested; + public string AnalysisState { get; private set; } = "idle"; + + public int? TotalCount { get; private set; } + + public int RemainingCount { get; private set; } + + public void SetAutomaticAnalysisEnabled(bool enabled) + { + lock (_lock) + { + if (_automaticAnalysisEnabled == enabled) + { + return; + } + + _automaticAnalysisEnabled = enabled; + if (!enabled) + { + SetStoppedUnlocked(); + return; + } + + if (AnalysisState == "stopped") + { + AnalysisState = "idle"; + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } + } + } + public void OnDeltaStarting(string filePath) { lock (_lock) { + if (!_automaticAnalysisEnabled) + { + return; + } + if (_activeJobs.TryGetValue(filePath, out var existingJob)) { DeltaJobTracker.Remove(existingJob); @@ -50,9 +92,92 @@ public void OnDeltaCompleted(string filePath) } } + public void ApplyQueue(ReviewQueue queue) + { + lock (_lock) + { + if (!_automaticAnalysisEnabled) + { + SetStoppedUnlocked(); + return; + } + + _activeJobs.Clear(); + var files = queue?.Files ?? Array.Empty(); + var jobs = new List(); + for (var i = 0; i < files.Count; i++) + { + var filePath = files[i]; + var job = new Job + { + Type = WebComponentConstants.JobTypes.DELTA, + State = i == 0 ? WebComponentConstants.StateTypes.RUNNING : WebComponentConstants.StateTypes.QUEUED, + File = new File { FileName = filePath }, + }; + _activeJobs[filePath] = job; + jobs.Add(job); + } + + DeltaJobTracker.Replace(jobs); + var remaining = queue?.Count ?? 0; + if (remaining <= 0) + { + RemainingCount = 0; + TotalCount = null; + AnalysisState = "idle"; + } + else + { + RemainingCount = remaining; + TotalCount = NextTotalCount(queue?.Done ?? 0, remaining); + AnalysisState = "running"; + } + + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } + } + + public void Clear() + { + lock (_lock) + { + _activeJobs.Clear(); + DeltaJobTracker.Clear(); + if (_automaticAnalysisEnabled) + { + AnalysisState = "idle"; + TotalCount = null; + RemainingCount = 0; + } + + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } + } + public void RequestViewUpdate() { ViewUpdateRequested?.Invoke(this, EventArgs.Empty); } + + private int NextTotalCount(int done, int remaining) + { + var total = done + remaining; + if (!TotalCount.HasValue) + { + return total; + } + + return Math.Min(total, Math.Max(TotalCount.Value, remaining)); + } + + private void SetStoppedUnlocked() + { + _activeJobs.Clear(); + DeltaJobTracker.Clear(); + AnalysisState = "stopped"; + TotalCount = null; + RemainingCount = 0; + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs index ee2d9bad..2b809e64 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs @@ -11,6 +11,7 @@ using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Interfaces.Telemetry; using Codescene.VSExtension.Core.Util; +using Newtonsoft.Json; namespace Codescene.VSExtension.Core.Application.Telemetry { @@ -19,24 +20,24 @@ namespace Codescene.VSExtension.Core.Application.Telemetry public class TelemetryManager : ITelemetryManager { private readonly ILogger _logger; - private readonly IProcessExecutor _executor; + private readonly IIdeServerClient _client; private readonly IDeviceIdStore _deviceIdStore; - private readonly ICliCommandProvider _cliCommandProvider; private readonly IExtensionMetadataProvider _extensionMetadataProvider; + private readonly ISettingsProvider _settingsProvider; [ImportingConstructor] public TelemetryManager( ILogger logger, - IProcessExecutor executor, + IIdeServerClient client, IDeviceIdStore deviceIdStore, - ICliCommandProvider cliCommandProvider, - IExtensionMetadataProvider extensionMetadataProvider) + IExtensionMetadataProvider extensionMetadataProvider, + [Import(AllowDefault = true)] ISettingsProvider settingsProvider = null) { _logger = logger; - _executor = executor; + _client = client; _deviceIdStore = deviceIdStore; - _cliCommandProvider = cliCommandProvider; _extensionMetadataProvider = extensionMetadataProvider; + _settingsProvider = settingsProvider; } /// @@ -50,7 +51,7 @@ public TelemetryManager( /// public async Task SendTelemetryAsync(string eventName, Dictionary additionalEventData = null, CancellationToken cancellationToken = default) { - if (!TelemetryUtils.IsTelemetryEnabled(_logger, _extensionMetadataProvider.GetEditorVersion())) + if (!IsTelemetryAllowed()) { return; } @@ -63,8 +64,17 @@ await _deviceIdStore.GetDeviceIdAsync(cancellationToken), _extensionMetadataProvider.GetVersion(), _extensionMetadataProvider.GetEditorVersion(), additionalEventData); - var arguments = _cliCommandProvider.SendTelemetryCommand(eventJson); - await _executor.ExecuteAsync(arguments, null, Constants.Timeout.TELEMETRYTIMEOUT, cancellationToken); + if (string.IsNullOrEmpty(eventJson)) + { + return; + } + + var eventPayload = JsonConvert.DeserializeObject(eventJson); + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeoutCts.CancelAfter(Constants.Timeout.TELEMETRYTIMEOUT); + await _client.TelemetryAsync(eventPayload, timeoutCts.Token); + } } catch (Exception e) { @@ -74,7 +84,7 @@ await _deviceIdStore.GetDeviceIdAsync(cancellationToken), public async Task SendErrorTelemetryAsync(Exception ex, string context, Dictionary extraData = null, CancellationToken cancellationToken = default) { - if (!TelemetryUtils.IsTelemetryEnabled(_logger, _extensionMetadataProvider.GetEditorVersion())) + if (!IsTelemetryAllowed()) { return; } @@ -104,5 +114,20 @@ public async Task SendErrorTelemetryAsync(Exception ex, string context, Dictiona _logger.Debug($"Unable to send error telemetry: {e.Message}"); } } + + private bool IsTelemetryAllowed() + { + if (TelemetryUtils.TelemetryEnabledOverrideForTests.HasValue) + { + return TelemetryUtils.TelemetryEnabledOverrideForTests.Value; + } + + if (_settingsProvider != null && !_settingsProvider.EnableTelemetry) + { + return false; + } + + return TelemetryUtils.IsTelemetryEnabled(_logger, _extensionMetadataProvider.GetEditorVersion()); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/CpuUsageChecker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/CpuUsageChecker.cs deleted file mode 100644 index caf650f0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/CpuUsageChecker.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.ComponentModel.Composition; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Util; - -namespace Codescene.VSExtension.Core.Application.Util -{ - [Export(typeof(ICpuUsageChecker))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class CpuUsageChecker : ICpuUsageChecker - { - private const int ThresholdOffset = 10; - - private readonly ILogger _logger; - private readonly CpuSampler _sampler; - - [ImportingConstructor] - public CpuUsageChecker(ILogger logger) - : this(logger, new CpuSampler(ThresholdOffset)) - { - } - - internal CpuUsageChecker( - ILogger logger, - Func> sampleFn, - Func coreCountProvider) - : this(logger, new CpuSampler(ThresholdOffset, sampleFn, coreCountProvider, CpuSampler.DefaultSamples, CpuSampler.DefaultSampleDelayMs)) - { - } - - internal CpuUsageChecker(ILogger logger, CpuSampler sampler) - { - _logger = logger; - _sampler = sampler; - } - - public async Task IsCpuTooBusyAsync() - { - try - { - return await _sampler.IsCpuTooBusyAsync(); - } - catch (Exception ex) - { - _logger?.Warn($"CPU check failed: {ex.Message}"); - return false; - } - } - - internal static int GetThresholdForCoreCount(int coreCount) - { - return CpuSampler.GetThresholdForCoreCount(coreCount, ThresholdOffset); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs index 095189d6..e160ddd9 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs @@ -18,6 +18,7 @@ public class DebounceService : IDebounceService, IDisposable private readonly object _lock = new object(); private readonly Dictionary _timers = new Dictionary(); + private readonly Dictionary _throttles = new Dictionary(); [ImportingConstructor] public DebounceService(ILogger logger) @@ -68,6 +69,33 @@ public void Debounce(string key, Action action, TimeSpan delay) } } + public void Throttle(string key, Action action, TimeSpan interval) + { + Action leading = null; + try + { + lock (_lock) + { + leading = BeginThrottle(key, action, interval); + } + + leading?.Invoke(); + } + catch (Exception e) + { + _logger.Error("Unable to perform throttled action", e); + } + } + + public void Cancel(string key) + { + lock (_lock) + { + CancelDebounce(key); + CancelThrottle(key); + } + } + public void Dispose() { lock (_lock) @@ -79,7 +107,100 @@ public void Dispose() } _timers.Clear(); + foreach (var window in _throttles.Values) + { + window.Timer?.Dispose(); + } + + _throttles.Clear(); } } + + private Action BeginThrottle(string key, Action action, TimeSpan interval) + { + if (!_throttles.TryGetValue(key, out var window)) + { + window = new ThrottleWindow(); + _throttles[key] = window; + } + + if (window.Open) + { + window.Trailing = action; + return null; + } + + window.Open = true; + window.Trailing = null; + window.Timer?.Dispose(); + window.Timer = new Timer(_ => FireThrottle(key), null, (int)interval.TotalMilliseconds, Timeout.Infinite); + return action; + } + + private void FireThrottle(string key) + { + Action trailing = null; + lock (_lock) + { + if (!_throttles.TryGetValue(key, out var window)) + { + return; + } + + window.Open = false; + trailing = window.Trailing; + window.Trailing = null; + } + + if (trailing == null) + { + return; + } + + try + { + _logger.Debug($"Performing throttled action... [{key}]"); + trailing(); + } + catch (Exception e) + { + _logger.Error("Unable to perform throttled action", e); + } + } + + private void CancelDebounce(string key) + { + if (!_timers.TryGetValue(key, out var existingCts)) + { + return; + } + + existingCts.Cancel(); + existingCts.Dispose(); + _timers.Remove(key); + } + + private void CancelThrottle(string key) + { + if (!_throttles.TryGetValue(key, out var window)) + { + return; + } + + window.Open = false; + window.Trailing = null; + window.Timer?.Dispose(); + window.Timer = null; + _throttles.Remove(key); + } + + private sealed class ThrottleWindow + { + public Timer Timer { get; set; } + + public Action Trailing { get; set; } + + public bool Open { get; set; } + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DroppingScheduledExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DroppingScheduledExecutor.cs deleted file mode 100644 index 2da7289b..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DroppingScheduledExecutor.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces; - -namespace Codescene.VSExtension.Core.Application.Util -{ - public class DroppingScheduledExecutor : IDisposable - { - private readonly object _lock = new object(); - private readonly Func _action; - private readonly CancellationToken _cancellationToken; - private readonly ILogger _logger; - - private TimeSpan _interval; - - private Timer _timer; - private bool _isRunning; - private bool _stopped; - private bool _disposed; - - public DroppingScheduledExecutor(Func action, CancellationToken cancellationToken, TimeSpan interval, ILogger logger) - { - _action = action ?? throw new ArgumentNullException(nameof(action)); - _cancellationToken = cancellationToken; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _interval = interval; - } - - public void Start() - { - lock (_lock) - { - if (_disposed) - { - return; - } - - if (_timer != null) - { - _logger.Warn("DroppingScheduledExecutor already started"); - return; - } - - _stopped = false; - _timer = new Timer(OnTimerCallback, null, _interval, _interval); - _logger.Debug($"DroppingScheduledExecutor started with interval: {_interval.TotalSeconds}s"); - } - } - - public void Stop() - { - lock (_lock) - { - _stopped = true; - _timer?.Dispose(); - _timer = null; - _logger.Debug("DroppingScheduledExecutor stopped"); - } - } - - public TimeSpan GetInterval() - { - lock (_lock) - { - return _interval; - } - } - - public void SetInterval(TimeSpan newInterval) - { - lock (_lock) - { - if (_disposed) - { - return; - } - - _interval = newInterval; - _logger.Debug($"DroppingScheduledExecutor interval updated to: {_interval.TotalSeconds}s"); - - if (_timer != null && !_stopped) - { - _timer.Dispose(); - _timer = new Timer(OnTimerCallback, null, _interval, _interval); - } - } - } - - public void Dispose() - { - lock (_lock) - { - if (_disposed) - { - return; - } - - _timer?.Dispose(); - _timer = null; - _disposed = true; - _logger.Debug("DroppingScheduledExecutor disposed"); - } - } - - private void OnTimerCallback(object state) - { - bool shouldExecute; - lock (_lock) - { - shouldExecute = !_stopped && !_disposed && !_isRunning; - if (shouldExecute) - { - _isRunning = true; - } - else if (_isRunning) - { - _logger.Debug("DroppingScheduledExecutor: dropping execution (previous still running)"); - } - } - - if (!shouldExecute) - { - return; - } - - if (_cancellationToken.IsCancellationRequested) - { - lock (_lock) - { - _isRunning = false; - } - - return; - } - - _ = ExecuteActionAsync(); - } - - private async Task ExecuteActionAsync() - { - try - { - _logger.Debug("DroppingScheduledExecutor: executing scheduled action"); - await _action(_cancellationToken); - } - catch (Exception ex) - { - _logger.Error("DroppingScheduledExecutor: error executing scheduled action", ex); - } - finally - { - lock (_lock) - { - _isRunning = false; - } - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/NoOpCpuUsageChecker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/NoOpCpuUsageChecker.cs deleted file mode 100644 index 9dc5bba1..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/NoOpCpuUsageChecker.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces.Util; - -namespace Codescene.VSExtension.Core.Application.Util -{ - public class NoOpCpuUsageChecker : ICpuUsageChecker - { - public Task IsCpuTooBusyAsync() - { - return Task.FromResult(false); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/PerFileRequestQueue.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/PerFileRequestQueue.cs deleted file mode 100644 index bfa6f163..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/PerFileRequestQueue.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Codescene.VSExtension.Core.Application.Util -{ - public class PerFileRequestQueue - { - private readonly object _lock = new object(); - private readonly Dictionary _states = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public bool TryStart(string fileKey, TRequest request) - { - if (string.IsNullOrWhiteSpace(fileKey)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(fileKey)); - } - - lock (_lock) - { - if (_states.TryGetValue(fileKey, out var state)) - { - if (!state.IsRunning) - { - state.IsRunning = true; - return true; - } - - return false; - } - - _states[fileKey] = new QueueState - { - IsRunning = true, - PendingRequest = request, - HasPending = false, - }; - return true; - } - } - - public void EnqueueLatest(string fileKey, TRequest request) - { - if (string.IsNullOrWhiteSpace(fileKey)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(fileKey)); - } - - lock (_lock) - { - if (!_states.TryGetValue(fileKey, out var state)) - { - _states[fileKey] = new QueueState - { - PendingRequest = request, - HasPending = true, - }; - return; - } - - state.PendingRequest = request; - state.HasPending = true; - } - } - - public bool CompleteAndGetNext(string fileKey, out TRequest nextRequest) - { - if (string.IsNullOrWhiteSpace(fileKey)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(fileKey)); - } - - lock (_lock) - { - if (!_states.TryGetValue(fileKey, out var state)) - { - nextRequest = default; - return false; - } - - if (!state.HasPending) - { - _states.Remove(fileKey); - nextRequest = default; - return false; - } - - nextRequest = state.PendingRequest; - state.HasPending = false; - state.PendingRequest = default; - state.IsRunning = true; - return true; - } - } - - public void Clear() - { - lock (_lock) - { - _states.Clear(); - } - } - - private class QueueState - { - public bool IsRunning { get; set; } - - public bool HasPending { get; set; } - - public TRequest PendingRequest { get; set; } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj index 58e713ed..e0616c78 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj @@ -5,6 +5,7 @@ false true true + $(NoWarn);VSTHRD003;VSTHRD011;VSTHRD110;VSTHRD200 @@ -14,17 +15,17 @@ - - - - - + + + + + + analyzers + all runtime; build; native; contentfiles; analyzers - - @@ -34,14 +35,4 @@ - - true - false - true - false - - - $(DefineConstants);FEATURE_PERIODIC_GIT_SCAN - $(DefineConstants);FEATURE_INITIAL_GIT_OBSERVER - \ No newline at end of file diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/Constants.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/Constants.cs index 841bd1a4..ca9ffc01 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/Constants.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/Constants.cs @@ -10,6 +10,7 @@ public class Timeout { public static readonly TimeSpan DEFAULTCLITIMEOUT = TimeSpan.FromMilliseconds(60000); // 60s public static readonly TimeSpan TELEMETRYTIMEOUT = TimeSpan.FromMilliseconds(5000); // 5s + public static readonly TimeSpan SERVERSTARTUPTIMEOUT = TimeSpan.FromMilliseconds(30000); } public class Titles diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs index 4700ba88..4778e6a7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs @@ -23,6 +23,7 @@ public class AceViewErrorTypes public class StateTypes { public const string RUNNING = "running"; + public const string QUEUED = "queued"; } public class JobTypes @@ -48,6 +49,8 @@ public class MessageTypes public const string OPENDOCSFORFUNCTION = "open-docs-for-function"; public const string CANCEL = "cancel"; public const string OPENSETTINGS = "open-settings"; + public const string STOPANALYSIS = "stop-analysis"; + public const string STARTANALYSIS = "start-analysis"; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Enums/Git/FileChangeType.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Enums/Git/FileChangeType.cs deleted file mode 100644 index 3ee36541..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Enums/Git/FileChangeType.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Enums.Git -{ - public enum FileChangeType - { - Create, - Change, - Delete, - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliCommandProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliCommandProvider.cs deleted file mode 100644 index 66936d3e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliCommandProvider.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Generic; -using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.Core.Models.Cli.Refactor; -using Codescene.VSExtension.Core.Models.Cli.Review; - -namespace Codescene.VSExtension.Core.Interfaces.Cli -{ - public interface ICliCommandProvider - { - string VersionCommand { get; } - - string DeviceIdCommand { get; } - - string RefactorCommand { get; } - - string ReviewFileContentCommand { get; } - - string RefactorPostCommand { get; } - - string SendTelemetryCommand(string jsonEvent); - - string GetReviewFileContentPayload(string filePath, string fileContent, string cachePath); - - string GetReviewDeltaCommand(string oldScore, string newScore); - - string GetRefactorWithDeltaResultPayload(string fileName, string fileContent, string cachePath, DeltaResponseModel deltaResult, PreFlightResponseModel preflight = null); - - string GetRefactorWithCodeSmellsPayload(string fileName, string fileContent, string cachePath, IList codeSmells, PreFlightResponseModel preflight = null); - - string GetPreflightSupportInformationCommand(bool force); - - string GetRefactorPostPayload(FnToRefactorModel fnToRefactor, bool skipCache, string token); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliExecutor.cs index 7d7f6fae..4a5654c9 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliExecutor.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliExecutor.cs @@ -11,10 +11,6 @@ namespace Codescene.VSExtension.Core.Interfaces.Cli { public interface ICliExecutor { - Task ReviewDeltaAsync(ReviewDeltaRequest request, CancellationToken cancellationToken = default); - - Task ReviewContentAsync(string filePath, string content, bool isBaseLine = false, CancellationToken cancellationToken = default); - Task GetFileVersionAsync(CancellationToken cancellationToken = default); Task GetDeviceIdAsync(CancellationToken cancellationToken = default); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliObjectScoreCreator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliObjectScoreCreator.cs deleted file mode 100644 index faa88526..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliObjectScoreCreator.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Interfaces.Cli -{ - public interface ICliObjectScoreCreator - { - /// - /// Raw base64 encoded old score. - /// Raw base64 encoded new score. - /// - /// score as json. - string Create(string oldScore, string newScore); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliServices.cs deleted file mode 100644 index 0b2a817f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliServices.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Interfaces.Cli; - -public interface ICliServices -{ - /// - /// Gets the CLI command provider. - /// - ICliCommandProvider CommandProvider { get; } - - /// - /// Gets the process executor for running CLI commands. - /// - IProcessExecutor ProcessExecutor { get; } - - /// - /// Gets the cache storage service for CLI operations. - /// - ICacheStorageService CacheStorage { get; } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICodeReviewer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICodeReviewer.cs index e12b3ccf..660f46b7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICodeReviewer.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICodeReviewer.cs @@ -9,14 +9,6 @@ namespace Codescene.VSExtension.Core.Interfaces.Cli { public interface ICodeReviewer { - Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default); - - Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null); - - Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null); - - Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null); - - Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null); + Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, CancellationToken cancellationToken = default); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs new file mode 100644 index 00000000..527c9d67 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs @@ -0,0 +1,57 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Refactor; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IIdeServerClient : IDisposable + { + event EventHandler ReviewReceived; + + event EventHandler DeltaReceived; + + event EventHandler ReviewFailed; + + event EventHandler WatchInventoryChanged; + + event EventHandler ServerStarted; + + event EventHandler QueueChanged; + + event EventHandler ServerError; + + ServerMetadata Metadata { get; } + + Task StartAsync(CancellationToken cancellationToken = default); + + Task RestartAsync(CancellationToken cancellationToken = default); + + Task PreflightAsync(bool force = false, CancellationToken cancellationToken = default); + + Task> FnsToRefactorAsync(FnsToRefactorRequestModel request, CancellationToken cancellationToken = default); + + Task RefactorAsync(RefactorPostRequestModel request, CancellationToken cancellationToken = default); + + Task TelemetryAsync(object eventPayload, CancellationToken cancellationToken = default); + + Task DeviceIdAsync(CancellationToken cancellationToken = default); + + Task CodeHealthRulesTemplateAsync(CancellationToken cancellationToken = default); + + Task CheckRulesAsync(string repoRoot, string path, CancellationToken cancellationToken = default); + + Task GetWatchInventoryAsync(string repoRoot, CancellationToken cancellationToken = default); + + void ReviewFiles(string repoRoot, IReadOnlyList files); + + void WatchFiles(string repoRoot, IReadOnlyList relativePaths = null); + + void StopWatchFiles(string repoRoot, IReadOnlyList relativePaths = null); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcess.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcess.cs new file mode 100644 index 00000000..8147f1fd --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcess.cs @@ -0,0 +1,28 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.IO; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IIdeServerProcess : IDisposable + { + event EventHandler Exited; + + event EventHandler ErrorDataReceived; + + Stream StandardInput { get; } + + Stream StandardOutput { get; } + + Stream StandardError { get; } + + int Id { get; } + + bool HasExited { get; } + + void BeginErrorReadLine(); + + void Kill(); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcessFactory.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcessFactory.cs new file mode 100644 index 00000000..5c6b6392 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcessFactory.cs @@ -0,0 +1,9 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IIdeServerProcessFactory + { + IIdeServerProcess Start(string fileName, string arguments); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IProcessExecutor.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IProcessExecutor.cs deleted file mode 100644 index 75ebf277..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IProcessExecutor.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Codescene.VSExtension.Core.Interfaces.Cli -{ - public interface IProcessExecutor - { - Task ExecuteAsync(string arguments, string content = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, string workingDirectory = null); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs new file mode 100644 index 00000000..db5825c8 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs @@ -0,0 +1,36 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Delta; +using Codescene.VSExtension.Core.Models.Cli.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IReviewPipeline + { + Task<(CliReviewModel Review, DeltaResponseModel Delta)> SubmitAsync(string repoRoot, ReviewSubmission submission); + + Task SubmitBatchAsync(string repoRoot, ReviewSubmission[] submissions); + + void Remove(string repoRoot, params ReviewDocument[] documents); + + void ReleaseBufferMonitorOwnership(string repoRoot, ReviewDocument document); + + void RestoreFromDiskIfBufferOwned(string repoRoot, ReviewDocument document); + + void SetWatchDeltaFilter(Func> filter); + + void Invalidate(); + + void Reset(); + + void SetActiveRepos(IReadOnlyCollection repoRoots); + + void SuspendBufferReviews(); + + void ResumeBufferReviews(); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelineFileAccess.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelineFileAccess.cs new file mode 100644 index 00000000..f437d46a --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelineFileAccess.cs @@ -0,0 +1,18 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IReviewPipelineFileAccess + { + ReviewDocument FindOpenDocument(string filePath); + + Task OpenDocumentAsync(string filePath); + + Task ReadFileBytesAsync(string filePath); + + bool IsVisible(string filePath); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelinePresentation.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelinePresentation.cs new file mode 100644 index 00000000..b576856a --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelinePresentation.cs @@ -0,0 +1,26 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public interface IReviewPipelinePresentation + { + void ReviewStarted(ReviewDocument document); + + void ReviewFinished(ReviewDocument document); + + void DeltaStarted(ReviewDocument document); + + void DeltaFinished(ReviewDocument document); + + void PresentReview(PresentedReview review); + + void PresentDelta(PresentedDelta delta); + + void Remove(ReviewDocument document); + + void Failed(Exception error); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IdeServerErrorDataEventArgs.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IdeServerErrorDataEventArgs.cs new file mode 100644 index 00000000..ba7e98ff --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IdeServerErrorDataEventArgs.cs @@ -0,0 +1,16 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; + +namespace Codescene.VSExtension.Core.Interfaces.Cli +{ + public class IdeServerErrorDataEventArgs : EventArgs + { + public IdeServerErrorDataEventArgs(string data) + { + Data = data; + } + + public string Data { get; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs index 2d49de04..6b07c49d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs @@ -7,5 +7,11 @@ public interface ISettingsProvider bool ShowDebugLogs { get; } string AuthToken { get; } + + int ServerWorkerThreads { get; } + + bool EnableAutomaticAnalysis { get; } + + bool EnableTelemetry { get; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IBatchGitIgnoreChecker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IBatchGitIgnoreChecker.cs deleted file mode 100644 index 75068565..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IBatchGitIgnoreChecker.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Generic; - -namespace Codescene.VSExtension.Core.Interfaces.Git -{ - public interface IBatchGitIgnoreChecker - { - HashSet FilterIgnored(IEnumerable absolutePaths); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IDocumentSaveEventSource.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IDocumentSaveEventSource.cs deleted file mode 100644 index 42f926cd..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IDocumentSaveEventSource.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; - -namespace Codescene.VSExtension.Core.Interfaces.Git -{ - public interface IDocumentSaveEventSource : IDisposable - { - event EventHandler DocumentSaved; - - void Start(); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitChangeLister.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitChangeLister.cs deleted file mode 100644 index a3d2fb59..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitChangeLister.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Git; - -namespace Codescene.VSExtension.Core.Interfaces.Git -{ - public interface IGitChangeLister - { - event EventHandler> FilesDetected; - - Task> GetAllChangedFilesAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default); - - Task> GetChangedFilesVsMergeBaseAsync(string gitRootPath, string workspacePath, CancellationToken cancellationToken = default); - - void Initialize(string gitRootPath, IReadOnlyCollection workspacePaths); - - void SetWorkspacePaths(IReadOnlyCollection workspacePaths); - - void StartPeriodicScanning(CancellationToken cancellationToken); - - void StopPeriodicScanning(); - - Task> CollectFilesFromRepoStateAsync(string gitRootPath, IReadOnlyCollection workspacePaths, CancellationToken cancellationToken = default); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitService.cs index 12a95bd6..36563823 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitService.cs @@ -1,20 +1,11 @@ // Copyright (c) CodeScene. All rights reserved. using System; -using System.Collections.Generic; namespace Codescene.VSExtension.Core.Interfaces.Git { public interface IGitService : IDisposable { - string GetBaselineCommit(string repoRootPath); - - string GetFileContentForCommit(string path); - - string GetFileContentForCommit(string path, string baselineCommit); - bool IsFileIgnored(string filePath); - - HashSet FilterIgnoredFiles(IEnumerable absolutePaths); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/ISavedFilesTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/ISavedFilesTracker.cs deleted file mode 100644 index 045f9257..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/ISavedFilesTracker.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Collections.Generic; - -namespace Codescene.VSExtension.Core.Interfaces.Git -{ - public interface ISavedFilesTracker - { - IEnumerable GetSavedFiles(); - - void ClearSavedFiles(); - - void RemoveFromTracker(string filePath); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs new file mode 100644 index 00000000..c60feddf --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs @@ -0,0 +1,22 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Codescene.VSExtension.Core.Interfaces.Git +{ + public interface IWorkspaceWatchCoordinator + { + long WatchSession { get; } + + Task SyncAsync(IReadOnlyList workspaceDirectories); + + Task SyncAsync(IReadOnlyList workspaceDirectories, long watchSession); + + void StopAll(); + + void SetEnabled(bool enabled); + + IReadOnlyCollection GetInventoryFiles(string repoRoot); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchHost.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchHost.cs new file mode 100644 index 00000000..d197e1b6 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchHost.cs @@ -0,0 +1,14 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Interfaces.Git +{ + public interface IWorkspaceWatchHost + { + IReadOnlyList GetDirtyDocuments(); + + void PruneMonitor(IReadOnlyList repoRoots, ISet keepPaths); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs index 72d74340..37952d45 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs @@ -1,13 +1,27 @@ // Copyright (c) CodeScene. All rights reserved. +using Codescene.VSExtension.Core.Models.Cli.Rpc; + namespace Codescene.VSExtension.Core.Interfaces { public interface ICodeHealthMonitorNotifier { + string AnalysisState { get; } + + int? TotalCount { get; } + + int RemainingCount { get; } + void OnDeltaStarting(string filePath); void OnDeltaCompleted(string filePath); void RequestViewUpdate(); + + void ApplyQueue(ReviewQueue queue); + + void Clear(); + + void SetAutomaticAnalysisEnabled(bool enabled); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/IIdeActivityTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/IIdeActivityTracker.cs deleted file mode 100644 index cea553d0..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/IIdeActivityTracker.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Interfaces -{ - public interface IIdeActivityTracker - { - bool IsIdeWindowActive(); - - void SetActiveForTesting(bool active); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/ICpuUsageChecker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/ICpuUsageChecker.cs deleted file mode 100644 index b6be39ff..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/ICpuUsageChecker.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Threading.Tasks; - -namespace Codescene.VSExtension.Core.Interfaces.Util -{ - public interface ICpuUsageChecker - { - Task IsCpuTooBusyAsync(); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/IDebounceService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/IDebounceService.cs index eb02c235..4f0b043c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/IDebounceService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/IDebounceService.cs @@ -7,5 +7,9 @@ namespace Codescene.VSExtension.Core.Interfaces.Util public interface IDebounceService { void Debounce(string key, Action action, TimeSpan delay); + + void Throttle(string key, Action action, TimeSpan interval); + + void Cancel(string key); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/AttachOutputHandlersArgs.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/AttachOutputHandlersArgs.cs deleted file mode 100644 index 089142d9..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/AttachOutputHandlersArgs.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; - -[assembly: InternalsVisibleTo("Codescene.VSExtension.Core.Tests")] - -namespace Codescene.VSExtension.Core.Models.Cli -{ - internal class AttachOutputHandlersArgs - { - public AttachOutputHandlersArgs( - Process process, - StringBuilder output, - StringBuilder error, - TaskCompletionSource outputTcs, - TaskCompletionSource errorTcs) - { - Process = process; - Output = output; - Error = error; - OutputTcs = outputTcs; - ErrorTcs = errorTcs; - } - - public Process Process { get; set; } - - public StringBuilder Output { get; set; } - - public StringBuilder Error { get; set; } - - public TaskCompletionSource OutputTcs { get; set; } - - public TaskCompletionSource ErrorTcs { get; set; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/DeltaResponseModel.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/DeltaResponseModel.cs index 93ea88a3..f11e0000 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/DeltaResponseModel.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/DeltaResponseModel.cs @@ -25,12 +25,18 @@ public class DeltaResponseModel /// Gets or sets if the file was not recently created, the old file score. /// [JsonProperty("old-score")] - public decimal OldScore { get; set; } + public decimal? OldScore { get; set; } /// /// Gets or sets represents the change in score for this Delta. An empty old- or new score is assumed to be 10.0 when comparing. /// [JsonProperty("score-change")] public decimal ScoreChange { get; set; } + + [JsonProperty("old-git-blob-sha")] + public string OldGitBlobSha { get; set; } + + [JsonProperty("new-git-blob-sha")] + public string NewGitBlobSha { get; set; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/ReviewDeltaRequest.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/ReviewDeltaRequest.cs deleted file mode 100644 index 2e515b6a..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/ReviewDeltaRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Models.Cli.Delta -{ - public class ReviewDeltaRequest - { - public string OldScore { get; set; } - - public string NewScore { get; set; } - - public string FilePath { get; set; } - - public string FileContent { get; set; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/CliReviewModel.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/CliReviewModel.cs index 605ab048..6aea972e 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/CliReviewModel.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/CliReviewModel.cs @@ -30,5 +30,8 @@ public class CliReviewModel /// [JsonProperty("code-health-rules-error")] public CliCodeHealthRulesErrorModel CodeHealthRulesError { get; set; } + + [JsonProperty("git-blob-sha")] + public string GitBlobSha { get; set; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs deleted file mode 100644 index 9cdcb942..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Newtonsoft.Json; - -namespace Codescene.VSExtension.Core.Models.Cli.Review -{ - public class ReviewRequestModel - { - [JsonProperty("path")] - public string FilePath { get; set; } - - [JsonProperty("file-content")] - public string FileContent { get; set; } - - [JsonProperty("cache-path")] - public string CachePath { get; set; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesRequestParams.cs new file mode 100644 index 00000000..fb15a4d9 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesRequestParams.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class CheckRulesRequestParams + { + [JsonProperty("repo-root")] + public string RepoRoot { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesResponse.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesResponse.cs new file mode 100644 index 00000000..6512dcf7 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class CheckRulesResponse + { + [JsonProperty("result")] + public string Result { get; set; } + + [JsonProperty("failed")] + public bool Failed { get; set; } + + [JsonProperty("parsing-errors")] + public IList ParsingErrors { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs new file mode 100644 index 00000000..2945d8d3 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs @@ -0,0 +1,10 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Delta; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class DeltaNotification : ResultNotification + { + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PreflightRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PreflightRequestParams.cs new file mode 100644 index 00000000..b80c1c7d --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PreflightRequestParams.cs @@ -0,0 +1,12 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class PreflightRequestParams + { + [JsonProperty("force", NullValueHandling = NullValueHandling.Ignore)] + public bool? Force { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedDelta.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedDelta.cs new file mode 100644 index 00000000..fdfab68c --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedDelta.cs @@ -0,0 +1,11 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Delta; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class PresentedDelta : ReviewSubmission + { + public DeltaResponseModel Result { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedReview.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedReview.cs new file mode 100644 index 00000000..75cb50f2 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedReview.cs @@ -0,0 +1,11 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Review; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class PresentedReview : ReviewSubmission + { + public CliReviewModel Result { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ResultNotification.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ResultNotification.cs new file mode 100644 index 00000000..6a0d23db --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ResultNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public abstract class ResultNotification + { + public string Id { get; set; } + + public string Path { get; set; } + + public string RepoRoot { get; set; } + + public TResult Result { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewDocument.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewDocument.cs new file mode 100644 index 00000000..2d327b91 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewDocument.cs @@ -0,0 +1,13 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewDocument + { + public string FilePath { get; set; } + + public string Content { get; set; } + + public bool IsDirty { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs new file mode 100644 index 00000000..12c1870f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewFailedNotification + { + public string Id { get; set; } + + public string Path { get; set; } + + public string RepoRoot { get; set; } + + public string Message { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFile.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFile.cs new file mode 100644 index 00000000..2e0ecb2a --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFile.cs @@ -0,0 +1,13 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewFile + { + public string Id { get; set; } + + public string RelPath { get; set; } + + public string Content { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFileParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFileParams.cs new file mode 100644 index 00000000..a7ce7c7b --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFileParams.cs @@ -0,0 +1,18 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class ReviewFileParams + { + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get; set; } + + [JsonProperty("rel-path")] + public string RelPath { get; set; } + + [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] + public string Content { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFilesParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFilesParams.cs new file mode 100644 index 00000000..6bc9c4bf --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFilesParams.cs @@ -0,0 +1,16 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class ReviewFilesParams + { + [JsonProperty("repo-root")] + public string RepoRoot { get; set; } + + [JsonProperty("files")] + public IList Files { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs new file mode 100644 index 00000000..416ec8e1 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs @@ -0,0 +1,10 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Review; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewNotification : ResultNotification + { + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs new file mode 100644 index 00000000..6d601978 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewQueue + { + public int Count { get; set; } + + public int Done { get; set; } + + public IReadOnlyList Files { get; set; } = new List(); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewSubmission.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewSubmission.cs new file mode 100644 index 00000000..7f3c6d50 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewSubmission.cs @@ -0,0 +1,17 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewSubmission + { + public ReviewDocument Document { get; set; } + + public string RelPath { get; set; } + + public string Content { get; set; } + + public bool UpdateDiagnosticsPane { get; set; } + + public bool UpdateMonitor { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerMetadata.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerMetadata.cs new file mode 100644 index 00000000..d6b90273 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerMetadata.cs @@ -0,0 +1,11 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ServerMetadata + { + public string Sha { get; set; } + + public string Version { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerStartEvent.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerStartEvent.cs new file mode 100644 index 00000000..0c254737 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerStartEvent.cs @@ -0,0 +1,11 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ServerStartEvent + { + public ServerMetadata Metadata { get; set; } + + public bool Restart { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/TelemetryRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/TelemetryRequestParams.cs new file mode 100644 index 00000000..f0b99adb --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/TelemetryRequestParams.cs @@ -0,0 +1,12 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class TelemetryRequestParams + { + [JsonProperty("event")] + public object Event { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchFilesParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchFilesParams.cs new file mode 100644 index 00000000..1da7c1e8 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchFilesParams.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class WatchFilesParams + { + [JsonProperty("repo-root")] + public string RepoRoot { get; set; } + + [JsonProperty("relative-paths", NullValueHandling = NullValueHandling.Ignore)] + public string[] RelativePaths { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventory.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventory.cs new file mode 100644 index 00000000..29a600b2 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventory.cs @@ -0,0 +1,13 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class WatchInventory + { + public string RepoRoot { get; set; } + + public IReadOnlyList Files { get; set; } = new List(); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventoryRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventoryRequestParams.cs new file mode 100644 index 00000000..020417a1 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventoryRequestParams.cs @@ -0,0 +1,12 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class WatchInventoryRequestParams + { + [JsonProperty("repo-root")] + public string RepoRoot { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Git/ChangeDetectionContext.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Git/ChangeDetectionContext.cs deleted file mode 100644 index a1c521b4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Git/ChangeDetectionContext.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using Codescene.VSExtension.Core.Interfaces.Git; - -namespace Codescene.VSExtension.Core.Models.Git -{ - public sealed class ChangeDetectionContext - { - public ChangeDetectionContext(string gitRootPath, IReadOnlyCollection workspacePaths, ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver) - { - GitRootPath = gitRootPath; - WorkspacePaths = workspacePaths ?? Array.Empty(); - SavedFilesTracker = savedFilesTracker; - OpenFilesObserver = openFilesObserver; - } - - public string GitRootPath { get; } - - public IReadOnlyCollection WorkspacePaths { get; } - - public ISavedFilesTracker SavedFilesTracker { get; } - - public IOpenFilesObserver OpenFilesObserver { get; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/WebComponent/Data/CodeHealthMonitorComponentData.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/WebComponent/Data/CodeHealthMonitorComponentData.cs index bd352196..e2be6736 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/WebComponent/Data/CodeHealthMonitorComponentData.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/WebComponent/Data/CodeHealthMonitorComponentData.cs @@ -13,5 +13,11 @@ public class CodeHealthMonitorComponentData public List FileDeltaData { get; set; } public List Jobs { get; set; } + + public string AnalysisState { get; set; } + + public int? TotalCount { get; set; } + + public int RemainingCount { get; set; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/CoreCountUtils.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/CoreCountUtils.cs deleted file mode 100644 index 28581f35..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/CoreCountUtils.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; - -namespace Codescene.VSExtension.Core.Util -{ - public static class CoreCountUtils - { - public static int GetParallelizationCountByCoreCount(int numberOfCores) - { - var calculatedCoreCount = (int)((long)numberOfCores * 33 / 100); - return Math.Max(1, calculatedCoreCount); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaJobTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaJobTracker.cs index cbe88603..b53defcf 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaJobTracker.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaJobTracker.cs @@ -68,6 +68,31 @@ public static void Remove(Job job) } } + public static void Replace(IEnumerable jobs) + { + lock (Running) + { + foreach (var job in Running.ToList()) + { + Running.Remove(job); + JobFinished?.Invoke(job); + } + + if (jobs == null) + { + return; + } + + foreach (var job in jobs) + { + if (Running.Add(job)) + { + JobStarted?.Invoke(job); + } + } + } + } + public static void Clear() { lock (Running) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaScoreText.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaScoreText.cs new file mode 100644 index 00000000..7af58381 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaScoreText.cs @@ -0,0 +1,24 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Delta; + +namespace Codescene.VSExtension.Core.Util +{ + public static class DeltaScoreText + { + public static string Format(DeltaResponseModel delta) + { + if (delta == null) + { + return null; + } + + if (!delta.OldScore.HasValue) + { + return delta.NewScore.ToString(); + } + + return delta.OldScore.Value + " → " + delta.NewScore; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaTelemetryHelper.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaTelemetryHelper.cs index cfc733f6..5844d17a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaTelemetryHelper.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaTelemetryHelper.cs @@ -41,14 +41,13 @@ public static void HandleDeltaTelemetryEvent( Task.Run(async () => { var delta = entry.Delta; - if (delta == null) + var hasAdditionalData = eventName == Constants.Telemetry.MONITORFILEADDED || eventName == Constants.Telemetry.MONITORFILEUPDATED; + if (hasAdditionalData && delta == null) { return; } Dictionary additionalData = null; - - var hasAdditionalData = eventName == Constants.Telemetry.MONITORFILEADDED || eventName == Constants.Telemetry.MONITORFILEUPDATED; if (hasAdditionalData) { additionalData = new Dictionary diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs index 81004a68..88586d7b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs @@ -1,6 +1,7 @@ // Copyright (c) CodeScene. All rights reserved. using System; +using System.Collections.Generic; using System.IO; using LibGit2Sharp; @@ -8,6 +9,58 @@ namespace Codescene.VSExtension.Core.Util { public static class GitPathDiscovery { + public static IReadOnlyList FindNestedRepositories(string directory) + { + var found = new List(); + foreach (var child in ChildDirectories(directory)) + { + var root = TryGetWorkingDirectory(child); + if (string.IsNullOrEmpty(root)) + { + continue; + } + + if (SameDirectory(root, child)) + { + found.Add(root); + } + } + + return found; + } + + public static string TryGetWorkingDirectory(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + var repoPath = Repository.Discover(path); + if (string.IsNullOrEmpty(repoPath)) + { + return null; + } + + using (var repo = new Repository(repoPath)) + { + var workingDirectory = repo.Info.WorkingDirectory; + if (string.IsNullOrEmpty(workingDirectory)) + { + return null; + } + + return PathNormalization.NormalizeWorkingDirectory(workingDirectory); + } + } + catch + { + return null; + } + } + public static (string workspacePath, string gitRootPath) Discover(string solutionPath) { var workspacePath = Directory.Exists(solutionPath) @@ -48,5 +101,49 @@ public static FileSystemWatcher CreateWatcher(string path) NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime, }; } + + private static IEnumerable ChildDirectories(string directory) + { + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return Array.Empty(); + } + + try + { + var children = new List(); + foreach (var child in Directory.EnumerateDirectories(directory)) + { + if (!IsExcludedFromScan(Path.GetFileName(child))) + { + children.Add(child); + } + } + + return children; + } + catch + { + return Array.Empty(); + } + } + + private static bool IsExcludedFromScan(string name) + { + if (string.IsNullOrEmpty(name) || name[0] == '.') + { + return true; + } + + return name.Equals("node_modules", StringComparison.OrdinalIgnoreCase); + } + + private static bool SameDirectory(string left, string right) + { + return string.Equals( + PathNormalization.NormalizeWorkingDirectory(left), + PathNormalization.NormalizeWorkingDirectory(right), + StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs new file mode 100644 index 00000000..accee9a5 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs @@ -0,0 +1,83 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Util +{ + public static class LogFields + { + public const int PathListLimit = 20; + + public static KeyValuePair Field(string key, object value) + { + return new KeyValuePair(key, value); + } + + public static string Format(params KeyValuePair[] fields) + { + if (fields == null || fields.Length == 0) + { + return string.Empty; + } + + var parts = new List(fields.Length); + foreach (var field in fields) + { + if (field.Value == null) + { + continue; + } + + parts.Add(field.Key + "=" + FormatValue(field.Value)); + } + + return string.Join(" ", parts); + } + + public static string FormatCappedList(IReadOnlyList items, int limit = PathListLimit) + { + if (items == null || items.Count == 0) + { + return "(none)"; + } + + if (items.Count <= limit) + { + return string.Join(", ", items); + } + + var shown = new string[limit]; + for (var i = 0; i < limit; i++) + { + shown[i] = items[i]; + } + + return string.Join(", ", shown) + " omitted=" + (items.Count - limit); + } + + private static string FormatValue(object value) + { + if (value is string text) + { + return text.IndexOf(' ') >= 0 ? JsonConvert.SerializeObject(text) : text; + } + + if (value is IEnumerable enumerable) + { + var items = new List(); + foreach (var item in enumerable) + { + items.Add(Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty); + } + + return FormatCappedList(items); + } + + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs new file mode 100644 index 00000000..12f02373 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs @@ -0,0 +1,203 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Codescene.VSExtension.Core.Application.Util; +using Microsoft.Win32.SafeHandles; + +namespace Codescene.VSExtension.Core.Util +{ + public static class RpcPath + { + private const uint FileGenericRead = 0x80000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileNameNormalized = 0x0; + + private static readonly ConcurrentDictionary CanonicalCache = new ConcurrentDictionary(StringComparer.Ordinal); + private static readonly ConcurrentDictionary LocalRepoRoots = new ConcurrentDictionary(StringComparer.Ordinal); + + public static string NormalizeFsPath(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return filePath ?? string.Empty; + } + + try + { + var normalized = Path.GetFullPath(filePath.Replace('/', Path.DirectorySeparatorChar)); + return PathNormalization.NormalizeWorkingDirectory(normalized).ToLowerInvariant(); + } + catch + { + return filePath.Replace('/', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar).ToLowerInvariant(); + } + } + + public static void RememberLocalRepoRoot(string repoRoot) + { + if (string.IsNullOrEmpty(repoRoot)) + { + return; + } + + var canonical = CanonicalRepoRoot(repoRoot); + LocalRepoRoots[canonical] = repoRoot; + } + + public static string ToLocalRepoRoot(string reported) + { + if (string.IsNullOrEmpty(reported)) + { + return reported ?? string.Empty; + } + + var canonical = CanonicalRepoRoot(reported); + return LocalRepoRoots.TryGetValue(canonical, out var local) ? local : reported; + } + + public static string CanonicalRepoRoot(string repoRoot) + { + if (string.IsNullOrEmpty(repoRoot)) + { + return NormalizeFsPath(repoRoot); + } + + var lookupKey = NormalizeFsPath(repoRoot); + if (CanonicalCache.TryGetValue(lookupKey, out var cached)) + { + return cached; + } + + var canonical = TryResolveCanonicalPath(repoRoot); + if (canonical != null) + { + CanonicalCache[lookupKey] = canonical; + return canonical; + } + + return lookupKey; + } + + public static string ToPosixRelPath(string relPath) + { + return (relPath ?? string.Empty).Replace('\\', '/'); + } + + public static string RelativePosix(string repoRoot, string absolutePath) + { + return ToPosixRelPath(PathUtilities.GetRelativePath(repoRoot, absolutePath)); + } + + public static bool PathsEqual(string left, string right) + { + if (string.Equals(NormalizeFsPath(left), NormalizeFsPath(right), StringComparison.Ordinal)) + { + return true; + } + + return string.Equals(CanonicalRepoRoot(left), CanonicalRepoRoot(right), StringComparison.Ordinal); + } + + public static string CombineRepo(string repoRoot, string posixRelPath) + { + var relative = (posixRelPath ?? string.Empty).Replace('/', Path.DirectorySeparatorChar); + return Path.Combine(repoRoot ?? string.Empty, relative); + } + + private static string TryResolveCanonicalPath(string repoRoot) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return null; + } + + try + { + var fullPath = Path.GetFullPath(repoRoot); + if (!Directory.Exists(fullPath) && !File.Exists(fullPath)) + { + return null; + } + + var openPath = ConvertExtendedPath(fullPath, toExtended: true); + using var handle = CreateFileW( + openPath, + FileGenericRead, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) + { + return null; + } + + var buffer = new StringBuilder(512); + var length = GetFinalPathNameByHandleW(handle, buffer, buffer.Capacity, FileNameNormalized); + if (length == 0 || length >= buffer.Capacity) + { + return null; + } + + var resolved = ConvertExtendedPath(buffer.ToString(), toExtended: false); + return NormalizeFsPath(resolved); + } + catch + { + return null; + } + } + + private static string ConvertExtendedPath(string path, bool toExtended) + { + if (toExtended) + { + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + return path; + } + + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + return @"\\?\UNC\" + path.Substring(2); + } + + return @"\\?\" + path; + } + + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path.Substring(7); + } + + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + return path.Substring(4); + } + + return path; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle hFile, StringBuilder lpszFilePath, int cchFilePath, uint dwFlags); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/TextUtils.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/TextUtils.cs index 2809f0a3..9531f0c7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/TextUtils.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/TextUtils.cs @@ -32,13 +32,33 @@ public static string ToSnakeCase(string input) /// A string with all line endings replaced by . If is /// null, returns null. public static string NormalizeLineEndings(string input) + { + return NormalizeLineEndings(input, Environment.NewLine); + } + + /// + /// Replaces every line ending in with . + /// + public static string NormalizeLineEndings(string input, string lineEnding) { if (input == null) { return null; } - return Regex.Replace(input, @"\r\n|\r|\n", Environment.NewLine); + var ending = string.IsNullOrEmpty(lineEnding) ? Environment.NewLine : lineEnding; + return Regex.Replace(input, @"\r\n|\r|\n", ending); + } + + public static string DominantLineEnding(string text) + { + if (string.IsNullOrEmpty(text)) + { + return Environment.NewLine; + } + + CountLineEndings(text, out var crlf, out var lf, out var cr); + return ChooseLineEnding(crlf, lf, cr); } public static string TrimForLogging(string value, int maxLength = 120) @@ -98,6 +118,73 @@ public static string ExtractLoggableJsonEntries(string jsonContent, int maxValue return FormatJsonProperties(jsonObject, maxValueLength); } + private static void CountLineEndings(string text, out int crlf, out int lf, out int cr) + { + crlf = 0; + lf = 0; + cr = 0; + for (var i = 0; i < text.Length; i++) + { + if (IsCrlfAt(text, i)) + { + crlf++; + i++; + continue; + } + + if (text[i] == '\n') + { + lf++; + continue; + } + + if (text[i] == '\r') + { + cr++; + } + } + } + + private static bool IsCrlfAt(string text, int index) + { + if (text[index] != '\r' || index + 1 >= text.Length) + { + return false; + } + + return text[index + 1] == '\n'; + } + + private static string ChooseLineEnding(int crlf, int lf, int cr) + { + if (PrefersCrlf(crlf, lf, cr)) + { + return "\r\n"; + } + + if (lf > 0 && lf >= cr) + { + return "\n"; + } + + if (cr > 0) + { + return "\r"; + } + + return Environment.NewLine; + } + + private static bool PrefersCrlf(int crlf, int lf, int cr) + { + if (crlf == 0) + { + return false; + } + + return crlf >= lf && crlf >= cr; + } + private static bool ShouldSkipLoggableJsonProperty(string propertyName) => propertyName == "file-content"; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/IdeActivityTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/IdeActivityTracker.cs deleted file mode 100644 index b2aba473..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/IdeActivityTracker.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.ComponentModel.Composition; -using Codescene.VSExtension.Core.Interfaces; -using Microsoft.VisualStudio.Shell; - -namespace Codescene.VSExtension.VS2022.Application.Adapters -{ - [Export(typeof(IIdeActivityTracker))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class IdeActivityTracker : IIdeActivityTracker - { - private bool? _testOverride; - private volatile bool _isWindowActive = true; - private bool _initialized; - - public IdeActivityTracker() - { - InitializeOnUIThread(); - } - - public bool IsIdeWindowActive() - { - if (_testOverride.HasValue) - { - return _testOverride.Value; - } - - return _isWindowActive; - } - - public void SetActiveForTesting(bool active) - { - _testOverride = active; - } - - private void InitializeOnUIThread() - { - ThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - - if (_initialized) - { - return; - } - - var mainWindow = System.Windows.Application.Current?.MainWindow; - if (mainWindow != null) - { - _isWindowActive = mainWindow.IsActive; - mainWindow.Activated += OnWindowActivated; - mainWindow.Deactivated += OnWindowDeactivated; - _initialized = true; - } - }).FileAndForget("IdeActivityTracker/Initialize"); - } - - private void OnWindowActivated(object sender, System.EventArgs e) - { - _isWindowActive = true; - } - - private void OnWindowDeactivated(object sender, System.EventArgs e) - { - _isWindowActive = false; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelineFileAccess.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelineFileAccess.cs new file mode 100644 index 00000000..f30cd8de --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelineFileAccess.cs @@ -0,0 +1,97 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Microsoft.VisualStudio.Shell; + +namespace Codescene.VSExtension.VS2022.Application.Adapters +{ + [Export(typeof(IReviewPipelineFileAccess))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed class VisualStudioReviewPipelineFileAccess : IReviewPipelineFileAccess + { + private readonly IOpenDocumentContentProvider _contentProvider; + private readonly IOpenFilesObserver _openFilesObserver; + + [ImportingConstructor] + public VisualStudioReviewPipelineFileAccess( + IOpenDocumentContentProvider contentProvider, + IOpenFilesObserver openFilesObserver) + { + _contentProvider = contentProvider; + _openFilesObserver = openFilesObserver; + } + + public ReviewDocument FindOpenDocument(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return null; + } + + var content = ThreadHelper.JoinableTaskFactory.Run(() => _contentProvider.GetContentForReviewAsync(filePath)); + if (content == null) + { + return null; + } + + return new ReviewDocument + { + FilePath = filePath, + Content = content, + IsDirty = true, + }; + } + + public Task OpenDocumentAsync(string filePath) + { + try + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new ReviewDocument + { + FilePath = filePath, + Content = File.ReadAllText(filePath), + IsDirty = false, + }); + } + catch + { + return Task.FromResult(null); + } + } + + public Task ReadFileBytesAsync(string filePath) + { + try + { + return Task.FromResult(File.Exists(filePath) ? File.ReadAllBytes(filePath) : null); + } + catch + { + return Task.FromResult(null); + } + } + + public bool IsVisible(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return false; + } + + return _openFilesObserver.GetAllVisibleFileNames() + .Any(path => string.Equals(path, filePath, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs new file mode 100644 index 00000000..1081ec15 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs @@ -0,0 +1,139 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using Codescene.VSExtension.Core.Application.Cache.Review; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Interfaces.Telemetry; +using Codescene.VSExtension.Core.Models; +using Codescene.VSExtension.Core.Models.Cache.Delta; +using Codescene.VSExtension.Core.Models.Cache.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; +using Codescene.VSExtension.VS2022.EditorMargin; +using Microsoft.VisualStudio.Shell; + +namespace Codescene.VSExtension.VS2022.Application.Adapters +{ + [Export(typeof(IReviewPipelinePresentation))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed class VisualStudioReviewPipelinePresentation : IReviewPipelinePresentation + { + private readonly IModelMapper _mapper; + private readonly IErrorListWindowHandler _errorListWindowHandler; + private readonly ICodeHealthMonitorNotifier _notifier; + private readonly IAsyncTaskScheduler _scheduler; + private readonly CodeSceneMarginSettingsManager _marginSettings; + private readonly ITelemetryManager _telemetryManager; + + [ImportingConstructor] + public VisualStudioReviewPipelinePresentation( + IModelMapper mapper, + IErrorListWindowHandler errorListWindowHandler, + ICodeHealthMonitorNotifier notifier, + IAsyncTaskScheduler scheduler, + CodeSceneMarginSettingsManager marginSettings, + ITelemetryManager telemetryManager) + { + _mapper = mapper; + _errorListWindowHandler = errorListWindowHandler; + _notifier = notifier; + _scheduler = scheduler; + _marginSettings = marginSettings; + _telemetryManager = telemetryManager; + } + + public void ReviewStarted(ReviewDocument document) + { + } + + public void ReviewFinished(ReviewDocument document) + { + } + + public void DeltaStarted(ReviewDocument document) + { + } + + public void DeltaFinished(ReviewDocument document) + { + } + + public void PresentReview(PresentedReview review) + { + if (review?.Document == null || review.Result == null) + { + return; + } + + var mapped = _mapper.Map(review.Document.FilePath, review.Result); + if (mapped == null) + { + return; + } + + new ReviewCacheService().Put(new ReviewCacheEntry(review.Content ?? string.Empty, review.Document.FilePath, mapped)); + if (!review.UpdateDiagnosticsPane) + { + return; + } + + _scheduler.Schedule(async ct => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _errorListWindowHandler.Handle(mapped); + _marginSettings.NotifyScoreUpdated(); + }); + } + + public void PresentDelta(PresentedDelta delta) + { + if (delta?.Document == null || !delta.UpdateMonitor) + { + return; + } + + var cache = new DeltaCacheService(); + var snapshot = cache.GetAll(); + cache.SetPresentationDelta(delta.Document.FilePath, delta.Result); + DeltaTelemetryHelper.HandleDeltaTelemetryEvent( + snapshot, + cache.GetAll(), + new DeltaCacheEntry(delta.Document.FilePath, string.Empty, string.Empty, delta.Result), + _telemetryManager); + _notifier.RequestViewUpdate(); + _marginSettings.NotifyScoreUpdated(); + } + + public void Remove(ReviewDocument document) + { + if (document == null || string.IsNullOrEmpty(document.FilePath)) + { + return; + } + + new ReviewCacheService().Invalidate(document.FilePath); + new DeltaCacheService().Invalidate(document.FilePath); + _scheduler.Schedule(async ct => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _errorListWindowHandler.Handle(new FileReviewModel + { + FilePath = document.FilePath, + FileLevel = new List(), + FunctionLevel = new List(), + }); + _marginSettings.NotifyScoreUpdated(); + }); + _notifier.RequestViewUpdate(); + } + + public void Failed(Exception error) + { + _notifier.RequestViewUpdate(); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs new file mode 100644 index 00000000..b67033a0 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs @@ -0,0 +1,132 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Application.Cache.Review; +using Codescene.VSExtension.Core.Consts; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Git; +using Codescene.VSExtension.Core.Interfaces.Telemetry; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using EnvDTE80; +using Microsoft.VisualStudio.Shell; + +namespace Codescene.VSExtension.VS2022.Application.Adapters +{ + [Export(typeof(IWorkspaceWatchHost))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed class VisualStudioWorkspaceWatchHost : IWorkspaceWatchHost + { + private readonly IOpenDocumentContentProvider _contentProvider; + private readonly ICodeHealthMonitorNotifier _notifier; + private readonly ITelemetryManager _telemetryManager; + + [ImportingConstructor] + public VisualStudioWorkspaceWatchHost( + IOpenDocumentContentProvider contentProvider, + ICodeHealthMonitorNotifier notifier, + ITelemetryManager telemetryManager) + { + _contentProvider = contentProvider; + _notifier = notifier; + _telemetryManager = telemetryManager; + } + + public IReadOnlyList GetDirtyDocuments() + { + var documents = new List(); + ThreadHelper.JoinableTaskFactory.Run(() => CollectDirtyDocumentsAsync(documents)); + return documents; + } + + public void PruneMonitor(IReadOnlyList repoRoots, ISet keepPaths) + { + var cache = new DeltaCacheService(); + var visibleBefore = cache.GetAll().Keys.ToList(); + if (cache.RemoveEntriesNotIn(keepPaths) == 0) + { + return; + } + + var visibleAfter = cache.GetAll(); + var removedCount = visibleBefore.Count(path => !visibleAfter.ContainsKey(path)); + SendFileRemovedTelemetry(removedCount); + _notifier.RequestViewUpdate(); + } + + private static DTE2 TryGetDte() + { + ThreadHelper.ThrowIfNotOnUIThread(); + var service = ServiceProvider.GlobalProvider.GetService(typeof(EnvDTE.DTE)); + if (service == null) + { + return null; + } + + return service as DTE2; + } + + private static bool IsDirtyDocument(EnvDTE.Document document) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (document == null || string.IsNullOrEmpty(document.FullName)) + { + return false; + } + + return !document.Saved; + } + + private void SendFileRemovedTelemetry(int count) + { + if (count == 0) + { + return; + } + + _ = Task.Run(async () => + { + for (var i = 0; i < count; i++) + { + await _telemetryManager.SendTelemetryAsync(Constants.Telemetry.MONITORFILEREMOVED); + } + }); + } + + private async Task CollectDirtyDocumentsAsync(List documents) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = TryGetDte(); + if (dte?.Documents == null) + { + return; + } + + foreach (EnvDTE.Document document in dte.Documents) + { + await AddIfDirtyAsync(documents, document); + } + } + + private async Task AddIfDirtyAsync(List documents, EnvDTE.Document document) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + if (!IsDirtyDocument(document)) + { + return; + } + + var fullName = document.FullName; + var content = await _contentProvider.GetContentForReviewAsync(fullName); + documents.Add(new ReviewDocument + { + FilePath = fullName, + Content = content ?? string.Empty, + IsDirty = true, + }); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VsDocumentSaveEventSource.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VsDocumentSaveEventSource.cs deleted file mode 100644 index 380a6bd4..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VsDocumentSaveEventSource.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.ComponentModel.Composition; -using Codescene.VSExtension.Core.Interfaces.Git; -using Microsoft.VisualStudio; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; - -namespace Codescene.VSExtension.VS2022.Application.Adapters -{ - [Export(typeof(IDocumentSaveEventSource))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class VsDocumentSaveEventSource : IDocumentSaveEventSource, IVsRunningDocTableEvents - { - private IVsRunningDocumentTable _runningDocumentTable; - private uint _cookie; - private bool _disposed; - private bool _started; - - public event EventHandler DocumentSaved; - - public void Start() - { - if (_started) - { - return; - } - - ThreadHelper.ThrowIfNotOnUIThread(); - - _runningDocumentTable = ServiceProvider.GlobalProvider.GetService(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable; - if (_runningDocumentTable != null) - { - _runningDocumentTable.AdviseRunningDocTableEvents(this, out _cookie); - } - - _started = true; - } - - public int OnAfterSave(uint docCookie) - { - ThreadHelper.ThrowIfNotOnUIThread(); - - var filePath = GetDocumentPath(docCookie); - if (!string.IsNullOrEmpty(filePath)) - { - DocumentSaved?.Invoke(this, filePath); - } - - return VSConstants.S_OK; - } - - public int OnAfterFirstDocumentLock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) - { - return VSConstants.S_OK; - } - - public int OnBeforeLastDocumentUnlock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) - { - return VSConstants.S_OK; - } - - public int OnAfterAttributeChange(uint docCookie, uint grfAttribs) - { - return VSConstants.S_OK; - } - - public int OnBeforeDocumentWindowShow(uint docCookie, int fFirstShow, IVsWindowFrame pFrame) - { - return VSConstants.S_OK; - } - - public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame pFrame) - { - return VSConstants.S_OK; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - if (_runningDocumentTable != null && _cookie != 0) - { - ThreadHelper.JoinableTaskFactory.Run(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - _runningDocumentTable.UnadviseRunningDocTableEvents(_cookie); - }); - } - } - - _disposed = true; - } - - private string GetDocumentPath(uint docCookie) - { - ThreadHelper.ThrowIfNotOnUIThread(); - - if (_runningDocumentTable == null) - { - return null; - } - - _runningDocumentTable.GetDocumentInfo( - docCookie, - out _, - out _, - out _, - out var moniker, - out _, - out _, - out var docData); - - if (docData != IntPtr.Zero) - { - System.Runtime.InteropServices.Marshal.Release(docData); - } - - return moniker; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs index 077150db..7a8a7ef6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -1,19 +1,15 @@ // Copyright (c) CodeScene. All rights reserved. -using System; using System.ComponentModel.Composition; using System.Threading; using System.Threading.Tasks; using Codescene.VSExtension.Core.Application.Cli; -using Codescene.VSExtension.Core.Application.Services; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Ace; using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; +using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; namespace Codescene.VSExtension.VS2022.Application.Cli { @@ -21,63 +17,23 @@ namespace Codescene.VSExtension.VS2022.Application.Cli [PartCreationPolicy(CreationPolicy.Shared)] public class CachingCodeReviewerProvider : ICodeReviewer { - private readonly CachingCodeReviewer _inner; - private readonly CodeHealthMonitorNotifier _notifier; - private readonly IAsyncTaskScheduler _scheduler; - private readonly IPreflightManager _preflightManager; + private readonly ICodeReviewer _inner; [ImportingConstructor] public CachingCodeReviewerProvider( ILogger logger, IModelMapper mapper, ICliExecutor executor, - ITelemetryManager telemetryManager, - IGitService git, - IAsyncTaskScheduler scheduler, - IPreflightManager preflightManager) + IPreflightManager preflightManager, + IReviewPipeline pipeline, + ISettingsProvider settings) { - _scheduler = scheduler; - _preflightManager = preflightManager; - _notifier = new CodeHealthMonitorNotifier(); - _notifier.ViewUpdateRequested += OnViewUpdateRequested; - - var baseReviewer = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, _preflightManager); - _inner = new CachingCodeReviewer( - innerReviewer: baseReviewer, - logger: logger, - git: git, - telemetryManager: telemetryManager, - notifier: _notifier); - } - - public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) - { - return _inner.ReviewAsync(path, content, isBaseline, operationGeneration, cancellationToken); - } - - public Task DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return _inner.DeltaAsync(review, currentCode, precomputedBaselineRawScore, operationGeneration, cancellationToken, baselineCommit); - } - - public Task<(FileReviewModel review, string baselineRawScore)> ReviewAndBaselineAsync(string path, string currentCode, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return _inner.ReviewAndBaselineAsync(path, currentCode, operationGeneration, cancellationToken, baselineCommit); - } - - public Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return _inner.ReviewWithDeltaAsync(path, content, operationGeneration, cancellationToken, baselineCommit); - } - - public Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - return _inner.GetOrComputeBaselineRawScoreAsync(path, baselineContent, operationGeneration, cancellationToken, baselineCommit); + _inner = new CodeReviewer(logger, mapper, executor, pipeline, preflightManager, settings); } - private void OnViewUpdateRequested(object sender, EventArgs e) + public Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, CancellationToken cancellationToken = default) { - _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); + return _inner.ReviewWithDeltaAsync(path, content, cancellationToken); } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/BatchGitIgnoreChecker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/BatchGitIgnoreChecker.cs deleted file mode 100644 index 69120e8e..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/BatchGitIgnoreChecker.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Application.Util; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Git; -using LibGit2Sharp; - -namespace Codescene.VSExtension.VS2022.Application.Git -{ - public class BatchGitIgnoreChecker : IBatchGitIgnoreChecker - { - private readonly ILogger _logger; - - public BatchGitIgnoreChecker(ILogger logger) - { - _logger = logger; - } - - public HashSet FilterIgnored(IEnumerable absolutePaths) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - var pathList = absolutePaths.ToList(); - - if (pathList.Count == 0) - { - return result; - } - - var pathsByRepo = GroupByRepository(pathList); - - foreach (var group in pathsByRepo) - { - FilterGroupByIgnoreStatus(group.Key, group.Value, result); - } - - return result; - } - - private static string TryDiscoverRepositoryPath(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - return null; - } - - var pathForDiscover = filePath; - if (Path.IsPathRooted(filePath)) - { - try - { - pathForDiscover = Path.GetFullPath(filePath); - } - catch (ArgumentException) - { - return null; - } - catch (NotSupportedException) - { - return null; - } - catch (PathTooLongException) - { - return null; - } - catch (IOException) - { - pathForDiscover = filePath; - } - } - - try - { - return Repository.Discover(pathForDiscover); - } - catch (LibGit2SharpException) - { - return null; - } - } - - private static void FilterPathsInRepo(Repository repo, string repoRoot, List paths, HashSet result) - { - foreach (var absolutePath in paths) - { - if (PathUtilities.IsInGitDirectory(absolutePath)) - { - continue; - } - - var relativePath = PathUtilities.GetRelativePath(repoRoot, absolutePath) - .Replace("\\", "/").Trim(); - - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = "."; - } - - if (!GitIgnoreSemantics.IsPathIgnoredConsideringIndex(repo, relativePath)) - { - result.Add(absolutePath); - } - } - } - - private void FilterGroupByIgnoreStatus(string repoPath, List paths, HashSet result) - { - if (string.IsNullOrEmpty(repoPath)) - { - result.UnionWith(paths); - return; - } - - try - { - using (var repo = new Repository(repoPath)) - { - var repoRoot = repo.Info.WorkingDirectory; - if (string.IsNullOrEmpty(repoRoot)) - { - result.UnionWith(paths); - return; - } - - FilterPathsInRepo(repo, repoRoot, paths, result); - } - } - catch (LibGit2SharpException ex) - { - _logger.Warn($"BatchGitIgnoreChecker: LibGit2Sharp error for repo {repoPath}: {ex.Message}"); - result.UnionWith(paths); - } - } - - private Dictionary> GroupByRepository(List paths) - { - var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (var path in paths) - { - var repoPath = TryDiscoverRepositoryPath(path); - var key = repoPath ?? string.Empty; - - if (!result.ContainsKey(key)) - { - result[key] = new List(); - } - - result[key].Add(path); - } - - return result; - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeListerService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeListerService.cs deleted file mode 100644 index ce70d470..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeListerService.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.ComponentModel.Composition; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Util; - -namespace Codescene.VSExtension.VS2022.Application.Git -{ - [Export(typeof(IGitChangeLister))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class GitChangeListerService : GitChangeLister - { - [ImportingConstructor] - public GitChangeListerService( - ISavedFilesTracker savedFilesTracker, - ISupportedFileChecker supportedFileChecker, - ILogger logger, - IGitService gitService, - IIdeActivityTracker ideActivityTracker, - ICpuUsageChecker cpuUsageChecker) - : base(savedFilesTracker, supportedFileChecker, logger, gitService, ideActivityTracker: ideActivityTracker, cpuUsageChecker: cpuUsageChecker) - { - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeObserver.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeObserver.cs deleted file mode 100644 index d35a3c25..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeObserver.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Cli; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; - -namespace Codescene.VSExtension.VS2022.Application.Git -{ - [Export(typeof(IGitChangeObserver))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class GitChangeObserver : IGitChangeObserver, IDisposable - { - [Import] - private ILogger _logger; - - [Import] - private ICodeReviewer _codeReviewer; - - [Import] - private ISupportedFileChecker _supportedFileChecker; - - [Import] - private IAsyncTaskScheduler _taskScheduler; - - [Import] - private IGitChangeLister _gitChangeLister; - - [Import] - private IGitService _gitService; - - [Import(AllowDefault = true)] - private IOpenDocumentContentProvider _openDocumentContentProvider; - - private GitChangeObserverCore _core; - private EventHandler _fileDeletedHandler; - private EventHandler _viewUpdateHandler; - - public GitChangeObserver() - { - } - - public GitChangeObserver( - ILogger logger, - ICodeReviewer codeReviewer, - ISupportedFileChecker supportedFileChecker, - IAsyncTaskScheduler taskScheduler, - IGitChangeLister gitChangeLister) - { - _logger = logger; - _codeReviewer = codeReviewer; - _supportedFileChecker = supportedFileChecker; - _taskScheduler = taskScheduler; - _gitChangeLister = gitChangeLister; - InitializeCore(); - } - - public event EventHandler FileDeletedFromGit; - - public event EventHandler ViewUpdateRequested; - - public ConcurrentQueue EventQueue => _core?.EventQueue; - - public FileSystemWatcher FileWatcher => _core?.FileWatcher; - - public Timer ScheduledTimer => _core?.ScheduledTimer; - - public void Initialize(string solutionPath, ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver, IReadOnlyCollection workspacePaths = null) - { - if (_core == null) - { - InitializeCore(); - } - - _core.Initialize(solutionPath, savedFilesTracker, openFilesObserver, async (baselineCommit) => await GetChangedFilesVsBaselineAsync(baselineCommit), _openDocumentContentProvider, workspacePaths); - } - - public void UpdateWorkspacePaths(IReadOnlyCollection workspacePaths) - { - _core?.UpdateWorkspacePaths(workspacePaths); - } - - public void Start() - { - _core?.Start(); - } - - public void CancelAndReset() - { - _core?.CancelAndReset(); - } - - public virtual async Task> GetChangedFilesVsBaselineAsync(string baselineCommit = null) - { - var core = _core; - if (core == null) - { - return new List(); - } - - return await core.GetChangedFilesVsBaselineAsync(baselineCommit); - } - - public void RemoveFromTracker(string filePath) - { - _core?.RemoveFromTracker(filePath); - } - - public TrackerManager GetTrackerManager() - { - return _core?.GetTrackerManager(); - } - - public async Task HandleFileChangeForTestingAsync(string filePath, List changedFiles) - { - await _core.HandleFileChangeForTestingAsync(filePath, changedFiles); - } - - public async Task HandleFileDeleteForTestingAsync(string filePath, List changedFiles) - { - await _core.HandleFileDeleteForTestingAsync(filePath, changedFiles); - } - - public bool ShouldProcessFileForTesting(string filePath, List changedFiles) - { - return _core.ShouldProcessFileForTesting(filePath, changedFiles); - } - - public void Dispose() - { - if (_core != null) - { - _core.FileDeletedFromGit -= _fileDeletedHandler; - _core.ViewUpdateRequested -= _viewUpdateHandler; - } - - _core?.Dispose(); - _core = null; - } - - private void InitializeCore() - { - _core = new GitChangeObserverCore(_logger, _codeReviewer, _supportedFileChecker, _taskScheduler, _gitChangeLister, _gitService); - - _fileDeletedHandler = (sender, args) => FileDeletedFromGit?.Invoke(this, args); - _viewUpdateHandler = OnViewUpdateRequested; - - _core.FileDeletedFromGit += _fileDeletedHandler; - _core.ViewUpdateRequested += _viewUpdateHandler; - } - - private void OnViewUpdateRequested(object sender, EventArgs e) - { - ViewUpdateRequested?.Invoke(this, e); - _taskScheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitService.cs index e81701aa..f2adeca5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitService.cs @@ -1,10 +1,8 @@ // Copyright (c) CodeScene. All rights reserved. using System; -using System.Collections.Generic; using System.ComponentModel.Composition; using System.IO; -using System.Linq; using Codescene.VSExtension.Core.Application.Git; using Codescene.VSExtension.Core.Interfaces; using Codescene.VSExtension.Core.Interfaces.Git; @@ -19,7 +17,6 @@ public class GitService : IGitService, IDisposable private readonly LibGit2SharpIgnoreChecker _ignoreChecker; private readonly CachingGitIgnoreChecker _cachingIgnoreChecker; - private readonly BatchGitIgnoreChecker _batchIgnoreChecker; private FileSystemWatcher _gitignoreWatcher; private FileSystemWatcher _globalExcludesWatcher; private string _watchedRepoRoot; @@ -31,101 +28,6 @@ public GitService(ILogger logger) _logger = logger; _ignoreChecker = new LibGit2SharpIgnoreChecker(logger); _cachingIgnoreChecker = new CachingGitIgnoreChecker(_ignoreChecker); - _batchIgnoreChecker = new BatchGitIgnoreChecker(logger); - } - - // TODO: Move to helper - public static string GetRelativePath(string basePath, string fullPath) - { - var baseUri = new Uri(AppendDirectorySeparatorChar(basePath)); - var fullUri = new Uri(fullPath); - return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar)); - } - - /// - /// Gets the baseline commit for delta analysis. - /// - If on a main branch: returns HEAD commit (compare against current committed state) - /// - If on a feature branch: returns merge-base with main branch. - /// - public string GetBaselineCommit(string repoRootPath) - { - if (string.IsNullOrEmpty(repoRootPath)) - { - return string.Empty; - } - - try - { - var repoPath = Repository.Discover(repoRootPath); - if (string.IsNullOrEmpty(repoPath)) - { - return string.Empty; - } - - using (var repo = new Repository(repoPath)) - { - return GetBaselineCommitFromRepo(repo); - } - } - catch (Exception e) - { - _logger.Error("Could not get baseline commit.", e); - return string.Empty; - } - } - - public string GetFileContentForCommit(string path) - { - return GetFileContentForCommit(path, null); - } - - public string GetFileContentForCommit(string path, string baselineCommit) - { - try - { - var repoPath = Repository.Discover(path); - if (string.IsNullOrEmpty(repoPath)) - { - _logger.Warn("Repository path is null. Aborting retrieval of file content for specific commit."); - return string.Empty; - } - - using (var repo = new Repository(repoPath)) - { - var commitHash = string.IsNullOrEmpty(baselineCommit) ? GetBaselineCommitFromRepo(repo) : baselineCommit; - - if (string.IsNullOrEmpty(commitHash)) - { - _logger.Debug("No baseline commit found, skipping file content retrieval."); - return string.Empty; - } - - var repoRoot = repo.Info.WorkingDirectory; - var relativePath = GetRelativePath(repoRoot, path).Replace("\\", "/"); - - var commit = repo.Lookup(commitHash); - if (commit == null) - { - _logger.Warn($"Could not find commit {commitHash}"); - return string.Empty; - } - - var entry = commit[relativePath]; - if (entry == null) - { - _logger.Debug($"File {relativePath} not found in commit {commitHash}"); - return string.Empty; - } - - var blob = (Blob)entry.Target; - return blob.GetContentText(); - } - } - catch (Exception e) - { - _logger.Warn($"Could not get file content for baseline commit: {e.Message}"); - return string.Empty; - } } public bool IsFileIgnored(string filePath) @@ -153,11 +55,6 @@ public bool IsFileIgnored(string filePath) } } - public HashSet FilterIgnoredFiles(IEnumerable absolutePaths) - { - return _batchIgnoreChecker.FilterIgnored(absolutePaths); - } - public void Dispose() { _gitignoreWatcher?.Dispose(); @@ -166,17 +63,6 @@ public void Dispose() _globalExcludesWatcher = null; } - // TODO: Move to helper - private static string AppendDirectorySeparatorChar(string path) - { - if (!path.EndsWith(Path.DirectorySeparatorChar.ToString())) - { - return path + Path.DirectorySeparatorChar; - } - - return path; - } - private static string ResolvePath(string path) { if (string.IsNullOrEmpty(path)) @@ -193,64 +79,6 @@ private static string ResolvePath(string path) return path; } - private string GetBaselineCommitFromRepo(Repository repository) - { - try - { - var currentBranchName = repository.Head?.FriendlyName; - if (string.IsNullOrEmpty(currentBranchName)) - { - _logger.Warn("Could not determine current branch name."); - return string.Empty; - } - - // If on main branch, use HEAD commit as baseline - if (MainBranchNames.IsMainBranch(repository, currentBranchName)) - { - var headCommit = repository.Head?.Tip?.Sha ?? string.Empty; - _logger.Debug($"On main branch '{currentBranchName}', using HEAD as baseline: {headCommit}"); - return headCommit; - } - - // On feature branch, try to find merge-base with main - var mergeBase = MainBranchMergeBaseSelector.FindClosest(repository, _logger); - if (mergeBase != null) - { - _logger.Debug($"Using merge-base with main as baseline: {mergeBase.Sha}"); - return mergeBase.Sha; - } - - // Fallback: try reflog-based approach - return GetBranchCreationCommitFromReflog(repository); - } - catch (Exception e) - { - _logger.Error("Could not get baseline commit.", e); - return string.Empty; - } - } - - private string GetBranchCreationCommitFromReflog(Repository repository) - { - try - { - var reflog = repository.Refs.Log(repository.Head.CanonicalName); - - var creationEntry = reflog - .Reverse() - .FirstOrDefault(entry => - entry.Message != null && - entry.Message.IndexOf("created from", StringComparison.OrdinalIgnoreCase) >= 0); - - return creationEntry?.To.Sha ?? string.Empty; - } - catch (Exception e) - { - _logger.Debug($"Could not get branch creation from reflog: {e.Message}"); - return string.Empty; - } - } - private void EnsureWatcherInitialized(string repoRoot) { if (_watchedRepoRoot == repoRoot && _gitignoreWatcher != null) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/IGitChangeObserver.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/IGitChangeObserver.cs deleted file mode 100644 index f979dccc..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/IGitChangeObserver.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces.Git; - -namespace Codescene.VSExtension.VS2022.Application.Git -{ - public interface IGitChangeObserver : IDisposable - { - event EventHandler FileDeletedFromGit; - - event EventHandler ViewUpdateRequested; - - void Initialize(string solutionPath, ISavedFilesTracker savedFilesTracker, IOpenFilesObserver openFilesObserver, IReadOnlyCollection workspacePaths = null); - - void UpdateWorkspacePaths(IReadOnlyCollection workspacePaths); - - void Start(); - - void CancelAndReset(); - - Task> GetChangedFilesVsBaselineAsync(string baselineCommit); - - void RemoveFromTracker(string filePath); - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SavedFilesTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SavedFilesTracker.cs deleted file mode 100644 index 62b5f408..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SavedFilesTracker.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using Codescene.VSExtension.Core.Application.Git; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Git; -using Microsoft.VisualStudio.Shell; - -namespace Codescene.VSExtension.VS2022.Application.Git -{ - [Export(typeof(ISavedFilesTracker))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class SavedFilesTracker : ISavedFilesTracker, IDisposable - { - private readonly object _initLock = new object(); - - [Import] - private IDocumentSaveEventSource _eventSource; - - [Import] - private IOpenFilesObserver _openFilesObserver; - - [Import] - private ILogger _logger; - - private SavedFilesTrackerCore _core; - private bool _disposed; - - public IEnumerable GetSavedFiles() - { - EnsureInitialized(); - return _core.GetSavedFiles(); - } - - public void ClearSavedFiles() - { - EnsureInitialized(); - _core.ClearSavedFiles(); - } - - public void RemoveFromTracker(string filePath) - { - EnsureInitialized(); - _core.RemoveFromTracker(filePath); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _core?.Dispose(); - } - - _disposed = true; - } - - private void EnsureInitialized() - { - if (_core != null) - { - return; - } - - lock (_initLock) - { - if (_core != null) - { - return; - } - - _core = new SavedFilesTrackerCore(_eventSource, _openFilesObserver, _logger); - ThreadHelper.JoinableTaskFactory.Run(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - _core.Start(); - }); - } - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SolutionProjectDiscovery.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SolutionProjectDiscovery.cs index 4779a979..7b503a2c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SolutionProjectDiscovery.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SolutionProjectDiscovery.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using Microsoft.VisualStudio; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; @@ -12,18 +11,20 @@ namespace Codescene.VSExtension.VS2022.Application.Git; public static class SolutionProjectDiscovery { + private static readonly Guid SolutionFolderGuid = new Guid("2150E333-8FDC-42A3-9474-1A3956D46DE8"); + public static HashSet GetProjectDirectories(IVsSolution solution, string solutionPath) { var directories = new HashSet(StringComparer.OrdinalIgnoreCase); TryAddSolutionDirectory(directories, solutionPath); if (solution == null) { - return DeduplicatePaths(directories); + return NormalizePaths(directories); } ThreadHelper.ThrowIfNotOnUIThread(); AddProjectDirectoriesFromSolution(solution, directories); - return DeduplicatePaths(directories); + return NormalizePaths(directories); } public static string GetProjectDirectory(IVsHierarchy hierarchy) @@ -34,20 +35,12 @@ public static string GetProjectDirectory(IVsHierarchy hierarchy) } ThreadHelper.ThrowIfNotOnUIThread(); - - var hr = hierarchy.GetProperty(VSConstants.VSITEMID_ROOT, (int)__VSHPROPID.VSHPROPID_ProjectDir, out var value); - if (hr != VSConstants.S_OK || value == null) - { - return null; - } - - var dir = value as string; - if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + if (IsSolutionFolder(hierarchy)) { return null; } - return Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return NormalizeExistingDirectory(TryGetProjectDirProperty(hierarchy) ?? TryGetCanonicalDirectory(hierarchy)); } private static void TryAddSolutionDirectory(HashSet directories, string solutionPath) @@ -85,7 +78,8 @@ private static void AddProjectDirectoriesFromSolution(IVsSolution solution, Hash { ThreadHelper.ThrowIfNotOnUIThread(); var guid = Guid.Empty; - var hr = solution.GetProjectEnum((uint)__VSENUMPROJFLAGS.EPF_LOADEDINSOLUTION, ref guid, out var enumHierarchies); + var flags = (uint)(__VSENUMPROJFLAGS.EPF_LOADEDINSOLUTION | __VSENUMPROJFLAGS.EPF_UNLOADEDINSOLUTION); + var hr = solution.GetProjectEnum(flags, ref guid, out var enumHierarchies); if (hr != VSConstants.S_OK || enumHierarchies == null) { return; @@ -102,17 +96,59 @@ private static void AddProjectDirectoriesFromSolution(IVsSolution solution, Hash } } - private static HashSet DeduplicatePaths(HashSet paths) + private static string TryGetProjectDirProperty(IVsHierarchy hierarchy) + { + ThreadHelper.ThrowIfNotOnUIThread(); + var hr = hierarchy.GetProperty(VSConstants.VSITEMID_ROOT, (int)__VSHPROPID.VSHPROPID_ProjectDir, out var value); + if (hr != VSConstants.S_OK) + { + return null; + } + + return value as string; + } + + private static string TryGetCanonicalDirectory(IVsHierarchy hierarchy) + { + ThreadHelper.ThrowIfNotOnUIThread(); + var hr = hierarchy.GetCanonicalName(VSConstants.VSITEMID_ROOT, out var name); + if (hr != VSConstants.S_OK || string.IsNullOrEmpty(name)) + { + return null; + } + + return Directory.Exists(name) ? name : Path.GetDirectoryName(name); + } + + private static bool IsSolutionFolder(IVsHierarchy hierarchy) + { + ThreadHelper.ThrowIfNotOnUIThread(); + var hr = hierarchy.GetGuidProperty(VSConstants.VSITEMID_ROOT, (int)__VSHPROPID.VSHPROPID_TypeGuid, out var typeGuid); + return hr == VSConstants.S_OK && typeGuid == SolutionFolderGuid; + } + + private static string NormalizeExistingDirectory(string directory) + { + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return null; + } + + return Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static HashSet NormalizePaths(HashSet paths) { - var normalizedList = paths - .Select(p => p.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)) - .Select(p => p.EndsWith(Path.DirectorySeparatorChar.ToString()) ? p : p + Path.DirectorySeparatorChar) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var filtered = normalizedList - .Where(p => !normalizedList.Any(o => !string.Equals(p, o, StringComparison.OrdinalIgnoreCase) && p.StartsWith(o, StringComparison.OrdinalIgnoreCase))) - .Select(p => p.TrimEnd(Path.DirectorySeparatorChar)) - .Distinct(StringComparer.OrdinalIgnoreCase); - return new HashSet(filtered, StringComparer.OrdinalIgnoreCase); + var normalized = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in paths) + { + var directory = NormalizeExistingDirectory(path); + if (directory != null) + { + normalized.Add(directory); + } + } + + return normalized; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Services/CodeHealthMonitorBinder.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Services/CodeHealthMonitorBinder.cs new file mode 100644 index 00000000..b7c8a1fc --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Services/CodeHealthMonitorBinder.cs @@ -0,0 +1,44 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.ComponentModel.Composition; +using Codescene.VSExtension.Core.Application.Services; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; +using Codescene.VSExtension.Core.Interfaces.Extension; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; + +namespace Codescene.VSExtension.VS2022.Application.Services; + +[Export(typeof(CodeHealthMonitorBinder))] +[PartCreationPolicy(CreationPolicy.Shared)] +public class CodeHealthMonitorBinder +{ + private readonly CodeHealthMonitorNotifier _notifier; + private readonly IAsyncTaskScheduler _scheduler; + + [ImportingConstructor] + public CodeHealthMonitorBinder( + CodeHealthMonitorNotifier notifier, + IIdeServerClient client, + IAsyncTaskScheduler scheduler, + ISettingsProvider settings) + { + _notifier = notifier; + _scheduler = scheduler; + _notifier.ViewUpdateRequested += OnViewUpdateRequested; + client.QueueChanged += OnQueueChanged; + _notifier.SetAutomaticAnalysisEnabled(settings?.EnableAutomaticAnalysis ?? true); + } + + private void OnQueueChanged(object sender, ReviewQueue queue) + { + _notifier.ApplyQueue(queue); + } + + private void OnViewUpdateRequested(object sender, EventArgs e) + { + _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj index ad6e96b3..256a72a1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -7,10 +7,6 @@ true false - true - false - true - false @@ -46,6 +42,7 @@ prompt 4 bin\Debug\Codescene.VSExtension.VS2022.xml + true pdbonly @@ -59,8 +56,6 @@ $(DefineConstants);FEATURE_ACE - $(DefineConstants);FEATURE_PERIODIC_GIT_SCAN - $(DefineConstants);FEATURE_INITIAL_GIT_OBSERVER @@ -68,23 +63,20 @@ - - + + + - - - - - + @@ -92,7 +84,7 @@ - + @@ -184,6 +176,11 @@ source.extension.cs + + + + + Menus.ctmenu @@ -206,46 +203,28 @@ - - 17.0.27 - - - 17.0.27 - - - + + compile; build; native; contentfiles; analyzers; buildtransitive - 0.31.0 + 0.32.0 - 2.0.323 - - - 0.41.1 + 2.0.324 - 3.1.11 + 2.5.301 - 17.0.31902.203 + 17.14.40265 - + runtime; build; native; contentfiles; analyzers; buildtransitive all - 1.0.1054.31 - - - 2.6.81 - - - 3.0.14 - - - 2.8.28 + 1.0.4258.31 all diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/EditorMargin/CodeSceneMargin.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/EditorMargin/CodeSceneMargin.cs index dfe86208..b35ae757 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/EditorMargin/CodeSceneMargin.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/EditorMargin/CodeSceneMargin.cs @@ -7,6 +7,7 @@ using System.Windows.Media; using Codescene.VSExtension.Core.Application.Cache.Review; using Codescene.VSExtension.Core.Models.Cache.Review; +using Codescene.VSExtension.Core.Util; using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Text; @@ -84,8 +85,7 @@ public ITextViewMargin GetTextViewMargin(string marginName) private static string GetDeltaScore(string path) { - var delta = new DeltaCacheService().GetDeltaForFile(path); - return delta != null ? $"{delta.OldScore} → {delta.NewScore}" : null; + return DeltaScoreText.Format(new DeltaCacheService().GetDeltaForFile(path)); } private static string GetReviewScore(string code, string path) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs index f75dad34..d0f895e6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel.Composition; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Codescene.VSExtension.Core.Application.Ace; using Codescene.VSExtension.Core.Interfaces; @@ -12,9 +13,10 @@ using Codescene.VSExtension.Core.Interfaces.Util; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +using Codescene.VSExtension.Core.Util; using Codescene.VSExtension.VS2022.EditorMargin; using Codescene.VSExtension.VS2022.Tagger; -using Codescene.VSExtension.VS2022.TermsAndPolicies; using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Text; @@ -28,6 +30,8 @@ namespace Codescene.VSExtension.VS2022.Handlers [TextViewRole(PredefinedTextViewRoles.Document)] // Only when it's a regular document (not e.g. an output window) public class EditorDocumentLifecycleHandler : IWpfTextViewCreationListener { + private static readonly ConditionalWeakTable OpenViews = new ConditionalWeakTable(); + [Import] private readonly ILogger _logger; @@ -46,9 +50,6 @@ public class EditorDocumentLifecycleHandler : IWpfTextViewCreationListener [Import] private readonly IErrorListWindowHandler _errorListWindowHandler; - [Import] - private readonly TermsAndPoliciesService _termsAndPoliciesService; - [Import] private readonly AceStaleChecker _aceStaleChecker; @@ -64,6 +65,9 @@ public class EditorDocumentLifecycleHandler : IWpfTextViewCreationListener [Import] private readonly IAceRefactorSuggestedActionsNotifier _aceRefactorSuggestedActionsNotifier; + [Import] + private readonly IReviewPipeline _reviewPipeline; + public void TextViewCreated(IWpfTextView textView) { var buffer = textView.TextBuffer; @@ -72,7 +76,7 @@ public void TextViewCreated(IWpfTextView textView) if (!isSupportedForReview) { - _logger.Debug($"File '{filePath}' is not supported. Skipping review."); + _logger.Debug("[editor] skipped " + LogFields.Format(LogFields.Field("path", filePath), LogFields.Field("reason", "unsupported"))); return; } @@ -80,17 +84,31 @@ public void TextViewCreated(IWpfTextView textView) if (isIgnored) { - _logger.Debug($"File '{filePath}' is ignored by Git. Skipping review."); + _logger.Debug("[editor] skipped " + LogFields.Format(LogFields.Field("path", filePath), LogFields.Field("reason", "gitignored"))); return; } _logger.Debug($"File opened: {filePath}. "); buffer.CurrentSnapshot.GetText(); + var openViews = OpenViews.GetOrCreateValue(buffer); + openViews.Value++; + SubscribeToSave(buffer, filePath); _scheduler.Schedule(ct => ReviewContentAsync(filePath, buffer)); buffer.Changed += (_, _) => { + if (!HasOpenViews(buffer)) + { + return; + } + + if (_gitService.IsFileIgnored(filePath)) + { + RemoveGitIgnoredOpenFile(filePath); + return; + } + if (ShouldCheckAceStaleStatus(filePath)) { _scheduler.Schedule(async ct => @@ -108,19 +126,105 @@ public void TextViewCreated(IWpfTextView textView) textView.Closed += (_, _) => { - _logger.Debug($"File closed: {filePath}..."); + openViews.Value--; + if (openViews.Value > 0) + { + return; + } - // TODO: Stop any pending analysis for optimization? + _logger.Debug("[editor] closed " + LogFields.Format(LogFields.Field("path", filePath), LogFields.Field("reason", "last-view"))); + OnLastViewClosed(filePath); }; } + private void SubscribeToSave(ITextBuffer buffer, string filePath) + { + if (!buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument document)) + { + return; + } + + if (buffer.Properties.ContainsProperty(typeof(TextDocumentFileActionEventArgs))) + { + return; + } + + buffer.Properties.AddProperty(typeof(TextDocumentFileActionEventArgs), true); + document.FileActionOccurred += (_, args) => + { + if (args.FileActionType != FileActionTypes.ContentSavedToDisk || _reviewPipeline == null) + { + return; + } + + var savedPath = string.IsNullOrEmpty(args.FilePath) ? filePath : args.FilePath; + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(savedPath); + if (string.IsNullOrEmpty(repoRoot)) + { + return; + } + + _reviewPipeline.ReleaseBufferMonitorOwnership(repoRoot, new ReviewDocument { FilePath = savedPath }); + }; + } + + private void OnLastViewClosed(string filePath) + { + _debounceService.Cancel(filePath); + ClearEditorDiagnostics(filePath); + RestoreOwnedBuffer(filePath); + } + + private void RemoveGitIgnoredOpenFile(string filePath) + { + _debounceService.Cancel(filePath); + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); + if (string.IsNullOrEmpty(repoRoot) || _reviewPipeline == null) + { + return; + } + + _reviewPipeline.Remove(repoRoot, new ReviewDocument { FilePath = filePath }); + } + + private void RestoreOwnedBuffer(string filePath) + { + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); + if (string.IsNullOrEmpty(repoRoot) || _reviewPipeline == null) + { + return; + } + + _reviewPipeline.RestoreFromDiskIfBufferOwned(repoRoot, new ReviewDocument { FilePath = filePath, Content = string.Empty }); + } + + private void ClearEditorDiagnostics(string filePath) + { + _scheduler.Schedule(async ct => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _errorListWindowHandler.Handle(new FileReviewModel + { + FilePath = filePath, + FileLevel = new System.Collections.Generic.List(), + FunctionLevel = new System.Collections.Generic.List(), + }); + _marginSettings.NotifyScoreUpdated(); + }); + } + + private bool HasOpenViews(ITextBuffer buffer) + { + return OpenViews.TryGetValue(buffer, out var openViews) && openViews.Value > 0; + } + /// /// Quick guard to determine if we should check ACE stale status. /// Performs only fast, cheap checks without accessing buffer snapshot. /// /// The path of the file being edited. /// True if the expensive stale check should be performed. - private static bool ShouldCheckAceStaleStatus(string filePath) + private bool ShouldCheckAceStaleStatus(string filePath) { // Skip if ACE tool window is not open or already marked as stale if (!AceToolWindow.IsCreated() || AceToolWindow.IsStale) @@ -193,21 +297,29 @@ private void CheckAndUpdateAceStaleStatus(string filePath, ITextBuffer buffer) /// private async Task ReviewContentAsync(string path, ITextBuffer buffer) { - try + if (!HasOpenViews(buffer)) { - var termsAccepted = await _termsAndPoliciesService.EvaluateTermsAndPoliciesAcceptanceAsync(); + return; + } - if (!termsAccepted) - { - _logger.Warn("Skipping CodeScene analysis, Terms & Policies have not been accepted.", true); - return; - } + if (_gitService.IsFileIgnored(path)) + { + RemoveGitIgnoredOpenFile(path); + return; + } + try + { var code = buffer.CurrentSnapshot.GetText(); var (result, _) = await _reviewer.ReviewWithDeltaAsync(path, code); await ApplyReviewResultsAsync(result, buffer); await _aceRefactorService.CheckContainsRefactorableFunctionsAsync(result, code); _aceRefactorSuggestedActionsNotifier.NotifyAceRefactorableDataChanged(buffer); + if (!HasOpenViews(buffer)) + { + ClearEditorDiagnostics(path); + RestoreOwnedBuffer(path); + } } catch (Exception e) { @@ -240,5 +352,10 @@ private string GetFilePath(ITextBuffer buffer) return string.Empty; } } + + private sealed class OpenViewCount + { + public int Value { get; set; } + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs index 5628abfa..1ced8fd2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs @@ -2,9 +2,12 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Codescene.VSExtension.Core.Application.Cache.Review; using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Cli; using Codescene.VSExtension.Core.Interfaces.Extension; using Codescene.VSExtension.Core.Interfaces.Git; using Codescene.VSExtension.VS2022.Application.Git; @@ -21,16 +24,22 @@ namespace Codescene.VSExtension.VS2022.Handlers; /// Handles solution-level events in Visual Studio (e.g. when a solution is opened or closed). /// Used to trigger cleanup or updates tied to the lifecycle of a loaded solution. /// -public class SolutionEventsHandler : IVsSolutionEvents, IDisposable +[ComVisible(true)] +[ClassInterface(ClassInterfaceType.None)] +public class SolutionEventsHandler : IVsSolutionEvents, IVsSolutionEvents7, IDisposable { private readonly object _initGate = new object(); private uint _cookie; private IVsSolution _solution; private BranchWatcherService _branchWatcher; - private IGitChangeObserver _gitChangeObserver; + private IWorkspaceWatchCoordinator _workspaceWatch; + private IReviewPipeline _reviewPipeline; + private ICodeHealthMonitorNotifier _codeHealthMonitorNotifier; private IAsyncTaskScheduler _scheduler; private IErrorListWindowHandler _errorListWindowHandler; private Task _solutionInitializationTask; + private string _initializedWorkspacePath; + private int _initGeneration; /// /// Subscribes to solution events using the Visual Studio shell service. @@ -39,6 +48,8 @@ public async Task InitializeAsync(IServiceProvider serviceProvider) { _scheduler = await VS.GetMefServiceAsync(); _errorListWindowHandler = await VS.GetMefServiceAsync(); + _reviewPipeline = await VS.GetMefServiceAsync(); + _codeHealthMonitorNotifier = await VS.GetMefServiceAsync(); await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); var isUiThread = ThreadHelper.CheckAccess(); @@ -57,7 +68,7 @@ public async Task InitializeAsync(IServiceProvider serviceProvider) VS.Events.SolutionEvents.OnAfterOpenFolder += SolutionEvents_OnAfterOpenFolder; } - _scheduler.Schedule(ct => EnsureSolutionInitializationAsync()); + ScheduleWorkspaceInitialization(null); } /// @@ -69,10 +80,10 @@ public int OnAfterCloseSolution(object pUnkReserved) { var cache = new DeltaCacheService(); cache.Clear(); - new BaselineReviewCacheService().Clear(); new ReviewCacheService().Clear(); new AceRefactorableFunctionsCacheService().Clear(); CacheGeneration.Increment(); + _codeHealthMonitorNotifier?.Clear(); _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); _scheduler.Schedule(ct => AceToolWindow.CloseAsync()); @@ -84,12 +95,6 @@ public int OnAfterCloseSolution(object pUnkReserved) _errorListWindowHandler?.ClearAll(); }); - _scheduler.Schedule(async ct => - { - var savedFilesTracker = await VS.GetMefServiceAsync(); - savedFilesTracker?.ClearSavedFiles(); - }); - Log(logger => { logger.Info("Solution or folder was closed. Clearing delta cache..."); @@ -110,7 +115,7 @@ public int OnAfterCloseSolution(object pUnkReserved) public int OnAfterOpenSolution(object pUnkReserved, int fNewSolution) { - _scheduler.Schedule(ct => EnsureSolutionInitializationAsync()); + ScheduleWorkspaceInitialization(null); return VSConstants.S_OK; } @@ -119,20 +124,23 @@ public int OnBeforeCloseSolution(object pUnkReserved) lock (_initGate) { _solutionInitializationTask = null; + _initializedWorkspacePath = null; + _initGeneration++; + _reviewPipeline?.SuspendBufferReviews(); } _branchWatcher?.Dispose(); _branchWatcher = null; - _gitChangeObserver?.CancelAndReset(); - _gitChangeObserver?.Dispose(); - _gitChangeObserver = null; + _workspaceWatch?.StopAll(); + _reviewPipeline?.Reset(); + _reviewPipeline?.SetActiveRepos(Array.Empty()); + _codeHealthMonitorNotifier?.Clear(); return VSConstants.S_OK; } public int OnAfterOpenProject(IVsHierarchy pHierarchy, int fAdded) { - _scheduler.Schedule(ct => RefreshWorkspacePathsAsync()); - return VSConstants.S_OK; + return ScheduleWorkspaceRefresh(); } public int OnQueryCloseProject(IVsHierarchy pHierarchy, int fRemoving, ref int pfCancel) => VSConstants.S_OK; @@ -143,7 +151,10 @@ public int OnBeforeCloseProject(IVsHierarchy pHierarchy, int fRemoved) return VSConstants.S_OK; } - public int OnAfterLoadProject(IVsHierarchy pStubHierarchy, IVsHierarchy pRealHierarchy) => VSConstants.S_OK; + public int OnAfterLoadProject(IVsHierarchy pStubHierarchy, IVsHierarchy pRealHierarchy) + { + return ScheduleWorkspaceRefresh(); + } public int OnQueryUnloadProject(IVsHierarchy pRealHierarchy, ref int pfCancel) => VSConstants.S_OK; @@ -151,6 +162,32 @@ public int OnBeforeCloseProject(IVsHierarchy pHierarchy, int fRemoved) public int OnQueryCloseSolution(object pUnkReserved, ref int pfCancel) => VSConstants.S_OK; + void IVsSolutionEvents7.OnAfterOpenFolder(string folderPath) + { + SolutionEvents_OnAfterOpenFolder(folderPath); + } + + void IVsSolutionEvents7.OnBeforeCloseFolder(string folderPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + SolutionEvents_OnBeforeCloseFolder(folderPath); + } + + void IVsSolutionEvents7.OnQueryCloseFolder(string folderPath, ref int pfCancel) + { + } + + void IVsSolutionEvents7.OnAfterCloseFolder(string folderPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + SolutionEvents_OnAfterCloseFolder(folderPath); + } + + void IVsSolutionEvents7.OnAfterLoadAllDeferredProjects() + { + ScheduleWorkspaceRefresh(); + } + public void Dispose() { ThreadHelper.ThrowIfNotOnUIThread(); @@ -179,10 +216,47 @@ private void Log(Func logAction) }); } - private void SolutionEvents_OnAfterOpenFolder(string obj) + private int ScheduleWorkspaceRefresh() { - ThreadHelper.ThrowIfNotOnUIThread(); - OnAfterOpenSolution(null, 0); + if (_workspaceWatch == null) + { + ScheduleWorkspaceInitialization(null); + return VSConstants.S_OK; + } + + _scheduler.Schedule(ct => RefreshWorkspacePathsAsync()); + return VSConstants.S_OK; + } + + private void ScheduleWorkspaceInitialization(string explicitPath) + { + _scheduler.Schedule(ct => InitializeWorkspaceSafeAsync(explicitPath)); + } + + private async Task InitializeWorkspaceSafeAsync(string explicitPath) + { + try + { + await EnsureSolutionInitializationAsync(explicitPath); + } + catch (Exception ex) + { + Log(logger => + { + logger.Error("Failed to initialize workspace.", ex); + return Task.CompletedTask; + }); + } + } + + private void SolutionEvents_OnAfterOpenFolder(string folderPath) + { + Log(logger => + { + logger.Info($"Folder opened: '{folderPath}'."); + return Task.CompletedTask; + }); + ScheduleWorkspaceInitialization(folderPath); } private void SolutionEvents_OnAfterCloseFolder(string obj) @@ -197,25 +271,45 @@ private void SolutionEvents_OnBeforeCloseFolder(string obj) OnBeforeCloseSolution(null); } - private async Task EnsureSolutionInitializationAsync() + private async Task EnsureSolutionInitializationAsync(string explicitPath) { - var solution = await VS.Solutions.GetCurrentSolutionAsync(); - var solutionPath = solution?.FullPath; + var solutionPath = explicitPath; + if (string.IsNullOrEmpty(solutionPath)) + { + var solution = await VS.Solutions.GetCurrentSolutionAsync(); + solutionPath = solution?.FullPath; + } + if (string.IsNullOrEmpty(solutionPath)) { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + solutionPath = GetOpenFolderPath(); + } + + if (string.IsNullOrEmpty(solutionPath)) + { + Log(logger => + { + logger.Debug("No workspace open; workspace watch not started."); + return Task.CompletedTask; + }); return; } + var folderWorkspace = Directory.Exists(solutionPath); Task task; lock (_initGate) { - if (_solutionInitializationTask != null) + if (_solutionInitializationTask != null + && string.Equals(_initializedWorkspacePath, solutionPath, StringComparison.OrdinalIgnoreCase)) { task = _solutionInitializationTask; } else { - _solutionInitializationTask = RunSolutionInitializationAsync(solutionPath); + var generation = ++_initGeneration; + _initializedWorkspacePath = solutionPath; + _solutionInitializationTask = RunSolutionInitializationAsync(solutionPath, folderWorkspace, generation); task = _solutionInitializationTask; } } @@ -223,12 +317,22 @@ private async Task EnsureSolutionInitializationAsync() await task; } - private async Task RunSolutionInitializationAsync(string solutionPath) + private async Task RunSolutionInitializationAsync(string solutionPath, bool folderWorkspace, int generation) { - new DeltaCacheService().Clear(); - new BaselineReviewCacheService().Clear(); - new ReviewCacheService().Clear(); - new AceRefactorableFunctionsCacheService().Clear(); + if (!IsCurrentInitialization(generation)) + { + return; + } + + ResumeBufferReviewsIfCurrent(generation); + ResetWorkspaceForInitialization(); + ClearAnalysisCaches(); + _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); + + if (!IsCurrentInitialization(generation)) + { + return; + } await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); _errorListWindowHandler?.ClearAll(); @@ -237,50 +341,30 @@ private async Task RunSolutionInitializationAsync(string solutionPath) _branchWatcher.StartWatching(solutionPath, (newBranch) => _scheduler.Schedule(ct2 => OnBranchChangedAsync(newBranch))); - await InitializeGitChangeObserverAsync(solutionPath); + await InitializeWorkspaceWatchAsync(solutionPath, folderWorkspace, generation); } - private async Task InitializeGitChangeObserverAsync(string solutionPath) + private async Task InitializeWorkspaceWatchAsync(string solutionPath, bool folderWorkspace, int generation) { try { - _gitChangeObserver = await VS.GetMefServiceAsync(); - if (_gitChangeObserver == null) + var coordinator = await PrepareWorkspaceWatchAsync(generation); + if (coordinator == null) { - Log(logger => - { - logger.Warn("Failed to obtain IGitChangeObserver service."); - return Task.CompletedTask; - }); return; } - var savedFilesTracker = await VS.GetMefServiceAsync(); - var openFilesObserver = await VS.GetMefServiceAsync(); - - if (savedFilesTracker == null || openFilesObserver == null) + var workspacePaths = await ResolveWorkspacePathsAsync(solutionPath, folderWorkspace, generation); + if (workspacePaths == null) { - Log(logger => - { - logger.Warn("Failed to obtain required services for GitChangeObserver."); - return Task.CompletedTask; - }); return; } - IReadOnlyCollection workspacePaths = null; - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - if (_solution != null) - { - workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); - } - - _gitChangeObserver.Initialize(solutionPath, savedFilesTracker, openFilesObserver, workspacePaths); - _gitChangeObserver.Start(); + await coordinator.SyncAsync(new List(workspacePaths)); Log(logger => { - logger.Info("GitChangeObserver initialized and started."); + logger.Info("Workspace watch initialized."); return Task.CompletedTask; }); } @@ -288,37 +372,126 @@ private async Task InitializeGitChangeObserverAsync(string solutionPath) { Log(logger => { - logger.Error("Failed to initialize GitChangeObserver.", ex); + logger.Error("Failed to initialize workspace watch.", ex); return Task.CompletedTask; }); } } + private async Task PrepareWorkspaceWatchAsync(int generation) + { + if (!IsCurrentInitialization(generation)) + { + return null; + } + + _workspaceWatch = await VS.GetMefServiceAsync(); + if (_workspaceWatch == null) + { + Log(logger => + { + logger.Warn("Failed to obtain IWorkspaceWatchCoordinator service."); + return Task.CompletedTask; + }); + return null; + } + + if (!IsCurrentInitialization(generation)) + { + return null; + } + + return _workspaceWatch; + } + + private async Task> ResolveWorkspacePathsAsync(string solutionPath, bool folderWorkspace, int generation) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + IReadOnlyCollection workspacePaths = Array.Empty(); + if (folderWorkspace) + { + workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(null, solutionPath); + } + else if (_solution != null) + { + workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); + } + + if (!IsCurrentInitialization(generation)) + { + return null; + } + + return workspacePaths; + } + private async Task RefreshWorkspacePathsAsync() { - if (_gitChangeObserver == null) + if (_workspaceWatch == null) { return; } - var solution = await VS.Solutions.GetCurrentSolutionAsync(); - var solutionPath = solution?.FullPath; + string solutionPath; + int generation; + lock (_initGate) + { + solutionPath = _initializedWorkspacePath; + generation = _initGeneration; + } + if (string.IsNullOrEmpty(solutionPath)) { return; } + var watchSession = _workspaceWatch.WatchSession; await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - if (_solution == null) + var workspacePaths = CurrentProjectDirectories(solutionPath, generation, watchSession); + if (workspacePaths == null) { return; } - var workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); - if (workspacePaths != null && workspacePaths.Count > 0) + await _workspaceWatch.SyncAsync(new List(workspacePaths), watchSession); + } + + private IReadOnlyCollection CurrentProjectDirectories(string solutionPath, int generation, long watchSession) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (!RefreshStillCurrent(generation, watchSession)) + { + return null; + } + + var workspacePaths = ProjectDirectories(solutionPath); + if (workspacePaths == null || !RefreshStillCurrent(generation, watchSession)) + { + return null; + } + + return workspacePaths; + } + + private bool RefreshStillCurrent(int generation, long watchSession) + { + return IsCurrentInitialization(generation) && watchSession == _workspaceWatch.WatchSession; + } + + private IReadOnlyCollection ProjectDirectories(string solutionPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (Directory.Exists(solutionPath)) + { + return SolutionProjectDiscovery.GetProjectDirectories(null, solutionPath); + } + + if (_solution == null) { - _gitChangeObserver.UpdateWorkspacePaths(workspacePaths); + return null; } + + return SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); } private async Task OnBranchChangedAsync(string newBranch) @@ -327,11 +500,11 @@ private async Task OnBranchChangedAsync(string newBranch) { Log(logger => { - logger.Info($"Branch switched to: '{newBranch}'. Clearing delta cache..."); + logger.Info($"Branch switched to: '{newBranch}'. Re-seeding workspace watches..."); return Task.CompletedTask; }); - _gitChangeObserver?.CancelAndReset(); + await RefreshWorkspacePathsAsync(); await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); @@ -345,4 +518,64 @@ private async Task OnBranchChangedAsync(string newBranch) }); } } + + private string GetOpenFolderPath() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_solution == null) + { + return null; + } + + _solution.GetSolutionInfo(out var directory, out var solutionFile, out _); + if (!string.IsNullOrEmpty(solutionFile)) + { + return null; + } + + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return null; + } + + return directory; + } + + private bool IsCurrentInitialization(int generation) + { + lock (_initGate) + { + return generation == _initGeneration; + } + } + + private void ResumeBufferReviewsIfCurrent(int generation) + { + lock (_initGate) + { + if (generation != _initGeneration) + { + return; + } + + _reviewPipeline?.ResumeBufferReviews(); + } + } + + private void ResetWorkspaceForInitialization() + { + _branchWatcher?.Dispose(); + _branchWatcher = null; + _workspaceWatch?.StopAll(); + _reviewPipeline?.Reset(); + _reviewPipeline?.SetActiveRepos(Array.Empty()); + } + + private void ClearAnalysisCaches() + { + new DeltaCacheService().Clear(); + new ReviewCacheService().Clear(); + new AceRefactorableFunctionsCacheService().Clear(); + _codeHealthMonitorNotifier?.Clear(); + } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs index 1f258722..2a92e46a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs @@ -4,7 +4,10 @@ using System.ComponentModel; using System.Runtime.InteropServices; using System.Threading.Tasks; +using Codescene.VSExtension.Core.Application.Cache.Review; using Codescene.VSExtension.Core.Application.Security; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.Core.Interfaces.Git; using Community.VisualStudio.Toolkit; using Microsoft.VisualStudio.Shell; @@ -24,6 +27,7 @@ public class General : BaseOptionModel { private byte[] _previousAuthToken; private bool _previousShowDebugLogs; + private bool _previousEnableAutomaticAnalysis = true; public General() { @@ -31,6 +35,7 @@ public General() _previousAuthToken = SecretBuffer.FromString(AuthToken); _previousShowDebugLogs = ShowDebugLogs; + _previousEnableAutomaticAnalysis = EnableAutomaticAnalysis; Saved += OnSettingsSaved; } @@ -61,6 +66,21 @@ public General() [PasswordPropertyText(true)] public string AuthToken { get; set; } = string.Empty; + [Category("Performance")] + [DisplayName("Server Worker Threads")] + [Description("Number of worker threads for the CodeScene CLI server. Use 0 to choose automatically (half of the logical processors).")] + public int ServerWorkerThreads { get; set; } = 0; + + [Category("General")] + [DisplayName("Enable Telemetry")] + [Description("Enable sending usage telemetry to CodeScene")] + public bool EnableTelemetry { get; set; } = true; + + [Category("General")] + [DisplayName("Enable Automatic Analysis")] + [Description("Automatically analyze file changes in the workspace. Open editors are still reviewed for diagnostics when this is off.")] + public bool EnableAutomaticAnalysis { get; set; } = true; + public override async Task LoadAsync() { await base.LoadAsync(); @@ -101,5 +121,28 @@ private void OnSettingsSaved(General obj) _previousShowDebugLogs = ShowDebugLogs; ShowDebugLogsChanged?.Invoke(this, EventArgs.Empty); } + + if (_previousEnableAutomaticAnalysis != EnableAutomaticAnalysis) + { + _previousEnableAutomaticAnalysis = EnableAutomaticAnalysis; + ApplyAutomaticAnalysis(EnableAutomaticAnalysis); + } + } + + private void ApplyAutomaticAnalysis(bool enabled) + { + var package = VS2022Package.Instance; + package?.JoinableTaskFactory.RunAsync(async () => + { + var coordinator = await VS.GetMefServiceAsync(); + coordinator?.SetEnabled(enabled); + var notifier = await VS.GetMefServiceAsync(); + notifier?.SetAutomaticAnalysisEnabled(enabled); + if (!enabled) + { + new DeltaCacheService().Clear(); + notifier?.RequestViewUpdate(); + } + }).FileAndForget("General/AutomaticAnalysis"); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs index f92c4f51..38b58fef 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs @@ -12,5 +12,11 @@ public class GeneralSettingsProvider : ISettingsProvider public bool ShowDebugLogs => General.Instance.ShowDebugLogs; public string AuthToken => General.Instance.AuthToken; + + public int ServerWorkerThreads => General.Instance.ServerWorkerThreads; + + public bool EnableAutomaticAnalysis => General.Instance.EnableAutomaticAnalysis; + + public bool EnableTelemetry => General.Instance.EnableTelemetry; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Telemetry/TelemetryNoticeService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Telemetry/TelemetryNoticeService.cs new file mode 100644 index 00000000..fe186321 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Telemetry/TelemetryNoticeService.cs @@ -0,0 +1,162 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Interfaces; +using Codescene.VSExtension.VS2022.Options; +using Community.VisualStudio.Toolkit; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Imaging; +using Microsoft.VisualStudio.Settings; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Shell.Settings; +using CodeSceneConstants = Codescene.VSExtension.Core.Consts.Constants; + +namespace Codescene.VSExtension.VS2022.Notices; + +[Export(typeof(TelemetryNoticeService))] +[PartCreationPolicy(CreationPolicy.Shared)] +public class TelemetryNoticeService : IVsInfoBarUIEvents +{ + private const string NoticeText = "CodeScene: Telemetry is enabled by default to help improve this extension. You can disable it in the settings."; + private const string OpenSettingsAction = "Open Settings"; + private const string NoticeShownProperty = "TelemetryNoticeShown"; + + private static readonly InfoBarModel Model = new InfoBarModel( + [new InfoBarTextSpan(NoticeText)], + [new InfoBarButton(OpenSettingsAction)], + KnownMonikers.StatusInformation, + isCloseButtonVisible: true); + + [Import] + private readonly ILogger _logger; + + private bool _shownThisSession; + + public async Task ShowOnceAsync() + { + try + { + const int maxAttempts = 50; + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + if (TryShow()) + { + return; + } + + await Task.Delay(200).ConfigureAwait(false); + } + + _logger?.Info("Telemetry notice could not be shown."); + } + catch (Exception e) + { + _logger?.Error("Failed to show telemetry notice.", e); + } + } + + public void OnClosed(IVsInfoBarUIElement infoBarUIElement) + { + } + + public void OnActionItemClicked(IVsInfoBarUIElement infoBarUIElement, IVsInfoBarActionItem actionItem) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (!string.Equals(actionItem.Text, OpenSettingsAction, StringComparison.Ordinal)) + { + return; + } + + infoBarUIElement.Close(); + ThreadHelper.JoinableTaskFactory.RunAsync(OpenSettingsAsync).FileAndForget("TelemetryNoticeService/OpenSettings"); + } + + private async Task OpenSettingsAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + await VS.Settings.OpenAsync(); + } + + private bool TryShow() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_shownThisSession || HasShownNotice()) + { + _shownThisSession = true; + return true; + } + + var factory = Package.GetGlobalService(typeof(SVsInfoBarUIFactory)) as IVsInfoBarUIFactory; + if (factory == null || !TryGetMainInfoBarHost(out var host)) + { + return false; + } + + var element = factory.CreateInfoBar(Model); + if (element == null) + { + return false; + } + + element.Advise(this, out _); + host.AddInfoBar(element); + _shownThisSession = true; + SetNoticeShown(); + return true; + } + + private bool TryGetMainInfoBarHost(out IVsInfoBarHost host) + { + host = null; + if (Package.GetGlobalService(typeof(SVsShell)) is not IVsShell vsShell) + { + return false; + } + + ThreadHelper.ThrowIfNotOnUIThread(); + if (vsShell.GetProperty((int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out var obj) != VSConstants.S_OK) + { + return false; + } + + if (obj is not IVsInfoBarHost infoBarHost) + { + return false; + } + + host = infoBarHost; + return true; + } + + private bool HasShownNotice() + { + ThreadHelper.ThrowIfNotOnUIThread(); + var store = GetOrCreateSettingsStore(); + return store.PropertyExists(CodeSceneConstants.Titles.SETTINGSCOLLECTION, NoticeShownProperty) + && store.GetBoolean(CodeSceneConstants.Titles.SETTINGSCOLLECTION, NoticeShownProperty); + } + + private void SetNoticeShown() + { + ThreadHelper.ThrowIfNotOnUIThread(); + var store = GetOrCreateSettingsStore(); + store.SetBoolean(CodeSceneConstants.Titles.SETTINGSCOLLECTION, NoticeShownProperty, true); + } + + private WritableSettingsStore GetOrCreateSettingsStore() + { + ThreadHelper.ThrowIfNotOnUIThread(); + var settingsManager = new ShellSettingsManager(ServiceProvider.GlobalProvider); + var store = settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); + if (!store.CollectionExists(CodeSceneConstants.Titles.SETTINGSCOLLECTION)) + { + store.CreateCollection(CodeSceneConstants.Titles.SETTINGSCOLLECTION); + } + + return store; + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/TermsAndPolicies/TermsAndPoliciesService.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/TermsAndPolicies/TermsAndPoliciesService.cs deleted file mode 100644 index a71e7356..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/TermsAndPolicies/TermsAndPoliciesService.cs +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Threading.Tasks; -using Codescene.VSExtension.Core.Interfaces; -using Codescene.VSExtension.Core.Interfaces.Telemetry; -using Community.VisualStudio.Toolkit; -using Microsoft.VisualStudio; -using Microsoft.VisualStudio.Imaging; -using Microsoft.VisualStudio.Settings; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; -using Microsoft.VisualStudio.Shell.Settings; -using Microsoft.VisualStudio.Threading; -using CodeSceneConstants = Codescene.VSExtension.Core.Consts.Constants; - -namespace Codescene.VSExtension.VS2022.TermsAndPolicies; - -[Export(typeof(TermsAndPoliciesService))] -[PartCreationPolicy(CreationPolicy.Shared)] -public class TermsAndPoliciesService : IVsInfoBarUIEvents -{ - private static readonly IVsInfoBarActionItem[] ActionItems = - [ - new InfoBarButton(CodeSceneConstants.Titles.ACCEPTTERMS), - new InfoBarButton(CodeSceneConstants.Titles.DECLINETERMS), - new InfoBarHyperlink(CodeSceneConstants.Titles.VIEWTERMS) - ]; - - private static readonly InfoBarModel Model = new InfoBarModel( - [new InfoBarTextSpan(CodeSceneConstants.Titles.TERMSINFO)], - ActionItems, - KnownMonikers.StatusInformation, - isCloseButtonVisible: false); - - [Import] - private readonly ILogger _logger; - - private bool _infoBarShownOnce; - private IVsInfoBarUIElement _currentTermsInfoBarUiElement; - - public async Task EvaluateTermsAndPoliciesAcceptanceAsync() - { - try - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - - var termsAccepted = GetAcceptedTerms(); - var factory = Package.GetGlobalService(typeof(SVsInfoBarUIFactory)) as IVsInfoBarUIFactory; - var uiElement = factory?.CreateInfoBar(Model); - - var setupIssue = _currentTermsInfoBarUiElement != null || factory == null || uiElement == null; - var skipInfoBar = setupIssue || termsAccepted || _infoBarShownOnce; - - if (skipInfoBar) - { - return termsAccepted; - } - - _currentTermsInfoBarUiElement = uiElement; - uiElement.Advise(this, out _); - - if (!TryGetMainInfoBarHost(out var mainHost)) - { - return false; - } - - mainHost.AddInfoBar(uiElement); - _infoBarShownOnce = true; - SendTelemetry(CodeSceneConstants.Telemetry.TERMSANDPOLICIESSHOWN); - - return false; - } - catch (Exception e) - { - _logger?.Error("Failed to evaluate Terms & Policies acceptance.", e); - return false; - } - } - - public void OnClosed(IVsInfoBarUIElement infoBarUIElement) - { - _logger.Debug("Terms & Policies bar has been closed."); - } - - public void OnActionItemClicked(IVsInfoBarUIElement infoBarUIElement, IVsInfoBarActionItem actionItem) - { - ThreadHelper.ThrowIfNotOnUIThread(); - - SendTelemetry(CodeSceneConstants.Telemetry.TERMSANDPOLICIESRESPONSE, actionItem.Text); - - switch (actionItem.Text) - { - case CodeSceneConstants.Titles.ACCEPTTERMS: - case CodeSceneConstants.Titles.DECLINETERMS: - var hasAccepted = actionItem.Text == CodeSceneConstants.Titles.ACCEPTTERMS; - SetAcceptedTerms(hasAccepted); - - _logger.Info($"User has {(hasAccepted ? "accepted" : "declined")} Terms & Policies. {(hasAccepted ? "Analysis will run when the file is updated or reopened." : string.Empty)}", true); - - infoBarUIElement.Close(); - break; - case CodeSceneConstants.Titles.VIEWTERMS: - const string termsUrl = "https://codescene.com/policies"; - if (Uri.TryCreate(termsUrl, UriKind.Absolute, out var termsUri) - && string.Equals(termsUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = termsUrl, - UseShellExecute = true, - }); - } - else - { - _logger.Info("Blocked opening Terms & Policies link: URL is not HTTPS."); - } - - break; - } - } - - private bool TryGetMainInfoBarHost(out IVsInfoBarHost host) - { - host = null; - - if (Package.GetGlobalService(typeof(SVsShell)) is not IVsShell vsShell) - { - return false; - } - - ThreadHelper.ThrowIfNotOnUIThread(); - - if (vsShell.GetProperty((int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out var obj) != VSConstants.S_OK) - { - return false; - } - - if (obj is not IVsInfoBarHost infoBarHost) - { - return false; - } - - host = infoBarHost; - return true; - } - - private void SendTelemetry(string eventName, string selection = "") - { - var package = VS2022Package.Instance; - if (package == null) - { - return; - } - - package.JoinableTaskFactory.RunAsync(async () => - { - Dictionary additionalData = null; - if (!string.IsNullOrEmpty(selection)) - { - additionalData = new Dictionary { { "selection", selection } }; - } - - var telemetryManager = await VS.GetMefServiceAsync(); - await telemetryManager.SendTelemetryAsync(eventName, additionalData, cancellationToken: package.PackageDisposalToken); - }).FileAndForget("TermsAndPoliciesService/SendTelemetry"); - } - - private bool GetAcceptedTerms() - { - ThreadHelper.ThrowIfNotOnUIThread(); - var store = GetOrCreateSettingsStore(); - - return store.PropertyExists(CodeSceneConstants.Titles.SETTINGSCOLLECTION, CodeSceneConstants.Titles.ACCEPTEDTERMSPROPERTY) && - store.GetBoolean(CodeSceneConstants.Titles.SETTINGSCOLLECTION, CodeSceneConstants.Titles.ACCEPTEDTERMSPROPERTY); - } - - private void SetAcceptedTerms(bool value) - { - ThreadHelper.ThrowIfNotOnUIThread(); - var store = GetOrCreateSettingsStore(); - - store.SetBoolean(CodeSceneConstants.Titles.SETTINGSCOLLECTION, CodeSceneConstants.Titles.ACCEPTEDTERMSPROPERTY, value); - _currentTermsInfoBarUiElement = null; - } - - private WritableSettingsStore GetOrCreateSettingsStore() - { - ThreadHelper.ThrowIfNotOnUIThread(); - - var settingsManager = new ShellSettingsManager(ServiceProvider.GlobalProvider); - var store = settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); - - if (!store.CollectionExists(CodeSceneConstants.Titles.SETTINGSCOLLECTION)) - { - store.CreateCollection(CodeSceneConstants.Titles.SETTINGSCOLLECTION); - } - - return store; - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs index 15f44083..f49870ae 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs @@ -19,6 +19,7 @@ using Microsoft.VisualStudio.Imaging; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Threading; using static Codescene.VSExtension.Core.Consts.Constants; using static Codescene.VSExtension.Core.Consts.WebComponentConstants; @@ -30,6 +31,7 @@ public class CodeSceneToolWindow : BaseToolWindow private static WebComponentUserControl _userControl; private static IDebounceService _updateViewDebounceService; private static IAsyncTaskScheduler _scheduler; + private static int _showScheduled; public override Type PaneType => typeof(Pane); @@ -40,7 +42,7 @@ public static async Task UpdateViewAsync() if (_updateViewDebounceService != null && _scheduler != null) { - _updateViewDebounceService.Debounce( + _updateViewDebounceService.Throttle( "CodeSceneToolWindow.UpdateView", () => _scheduler.Schedule(ct => RunActualUpdateAsync()), TimeSpan.FromMilliseconds(UpdateViewDebounceMs)); @@ -97,8 +99,7 @@ private static async Task RunActualUpdateAsync() { if (_userControl == null) { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - await ShowAsync(); + ScheduleShowAfterInitialization(); return; } @@ -120,6 +121,38 @@ private static async Task RunActualUpdateAsync() _scheduler.Schedule(ct => _userControl.UpdateViewAsync(message)); } + private static void ScheduleShowAfterInitialization() + { + if (Interlocked.Exchange(ref _showScheduled, 1) == 1) + { + return; + } + + var package = VS2022Package.Instance; + if (package == null) + { + Interlocked.Exchange(ref _showScheduled, 0); + return; + } + + package.JoinableTaskFactory.RunAsync(async () => + { + try + { + await package.WaitForInitializationAsync(); + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.PackageDisposalToken); + if (_userControl == null) + { + await ShowAsync(); + } + } + finally + { + Interlocked.Exchange(ref _showScheduled, 0); + } + }).FileAndForget("CodeSceneToolWindow/Show"); + } + [Guid("A9FF6E0A-51FE-4713-8123-6B75EFC3E2C5")] internal class Pane : ToolWindowPane, IVsWindowFrameNotify3 { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/ShowDiffHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/ShowDiffHandler.cs index c591ac7b..0030b290 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/ShowDiffHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/ShowDiffHandler.cs @@ -24,8 +24,6 @@ public async Task ShowDiffWindowAsync() { var cache = _aceManager.GetCachedRefactoredCode(); var newCode = cache.Refactored.Code; - - var normalizedNewCode = TextUtils.NormalizeLineEndings(newCode); var tempOriginalPath = Path.GetTempFileName(); var tempRefactoredPath = Path.GetTempFileName(); @@ -52,11 +50,13 @@ public async Task ShowDiffWindowAsync() snapshot.GetLineFromLineNumber(start).Start.Position, snapshot.GetLineFromLineNumber(end).EndIncludingLineBreak.Position - snapshot.GetLineFromLineNumber(start).Start.Position); var original = snapshot.GetText(); + var lineEnding = TextUtils.DominantLineEnding(original); + var normalizedNewCode = TextUtils.NormalizeLineEndings(newCode, lineEnding); var refactored = original.Remove(span.Start, span.Length).Insert(span.Start, normalizedNewCode); // Write files (could be moved to background thread if desired, but it's fast) - File.WriteAllText(tempOriginalPath, original); - File.WriteAllText(tempRefactoredPath, refactored); + File.WriteAllText(tempOriginalPath, TextUtils.NormalizeLineEndings(original, lineEnding)); + File.WriteAllText(tempRefactoredPath, TextUtils.NormalizeLineEndings(refactored, lineEnding)); var diffService = await VS.GetServiceAsync(); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/WebComponentMessageHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/WebComponentMessageHandler.cs index 501df29f..2b7873f3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/WebComponentMessageHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/Handlers/WebComponentMessageHandler.cs @@ -44,7 +44,6 @@ public WebComponentMessageHandler(WebComponentUserControl control) public async Task HandleAsync(string message) { _logger ??= await VS.GetMefServiceAsync(); - _showDocsHandler ??= await VS.GetMefServiceAsync(); MessageObj msgObject; try @@ -131,7 +130,7 @@ private async Task ProcessMessageAsync(MessageObj msgObject, ILogger log await HandleOpenLogOutputAsync(); break; default: - logger.Debug($"Unknown message type: {msgObject.MessageType}"); + await HandleUnmatchedMessageAsync(msgObject, logger); break; } } @@ -153,6 +152,11 @@ private async Task HandleInitAsync(MessageObj msgObject, ILogger logger) // Mark the window as initialized, which will process any pending messages await _control.MarkAsInitializedAsync(); + + if (source == ViewTypes.HOME) + { + await CodeSceneToolWindow.UpdateViewAsync(); + } } private async Task HandleCopyCodeAsync(MessageObj msgObject, ILogger logger) @@ -257,6 +261,7 @@ private async Task HandleOpenDocsForFunctionAsync(MessageObj msgObject) { var payload = msgObject.Payload.ToObject(); _logger.Debug($"Opening '{payload.DocType}'..."); + _showDocsHandler ??= await VS.GetMefServiceAsync(); var category = DocumentationMappings.DocNameMap[payload.DocType] ?? string.Empty; var fn = await AceUtils.GetRefactorableFunctionDeltaAsync(new GetRefactorableFunctionsModel @@ -302,6 +307,23 @@ await onClickRefactoringHandler.HandleAsync( AceConstants.AceEntryPoint.CODEVISION); } + private async Task HandleUnmatchedMessageAsync(MessageObj msgObject, ILogger logger) + { + if (msgObject.MessageType == MessageTypes.STOPANALYSIS) + { + await SetAutomaticAnalysisAsync(false); + return; + } + + if (msgObject.MessageType == MessageTypes.STARTANALYSIS) + { + await SetAutomaticAnalysisAsync(true); + return; + } + + logger.Debug($"Unknown message type: {msgObject.MessageType}"); + } + private async Task HandleOpenSettingsAsync() { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); @@ -310,6 +332,13 @@ private async Task HandleOpenSettingsAsync() SendTelemetry(Constants.Telemetry.OPENSETTINGS); } + private async Task SetAutomaticAnalysisAsync(bool enabled) + { + var general = General.Instance; + general.EnableAutomaticAnalysis = enabled; + await general.SaveAsync(); + } + // Currently, we only receive this message from the ACE view if the content is marked as 'stale'. private async Task HandleCloseAsync() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/WebComponentUserControl.xaml.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/WebComponentUserControl.xaml.cs index b626ae98..dcdb36b2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/WebComponentUserControl.xaml.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/WebComponentUserControl.xaml.cs @@ -20,6 +20,7 @@ using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Shell; using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.Wpf; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -37,6 +38,10 @@ public partial class WebComponentUserControl : UserControl private string _host; private bool _initialized; private string _pendingMessage; + private object _payload; + private string _view; + private Task _webViewInitTask; + private bool _recreating; private WebView2AirspaceGuard _airspaceGuard; public WebComponentUserControl(WebComponentPayload payload, ILogger logger) @@ -94,7 +99,13 @@ public async Task UpdateViewAsync(T message) { _pendingMessage = messageString; _logger.Debug("WebView2 is unavailable; view update queued for retry."); - SchedulePendingMessageRetry(); + if (IsWebViewDead()) + { + _logger.Info("WebView2 is dead; recreating control."); + ThreadHelper.JoinableTaskFactory.RunAsync(RecreateWebViewAsync) + .FileAndForget("WebComponentUserControl/RecreateWebView"); + } + return; } @@ -162,12 +173,23 @@ private CoreWebView2 TryGetCoreWebView2() { return webView.CoreWebView2; } - catch (InvalidOperationException) + catch (InvalidOperationException ex) { + _logger.Debug($"WebView2 CoreWebView2 unavailable: {ex.Message}"); return null; } } + private bool IsWebViewDead() + { + if (_webViewInitTask == null || !_webViewInitTask.IsCompleted) + { + return false; + } + + return TryGetCoreWebView2() == null; + } + private void TryFlushPendingWebMessage() { if (string.IsNullOrEmpty(_pendingMessage)) @@ -192,28 +214,73 @@ private void TryFlushPendingWebMessage() } } - private void SchedulePendingMessageRetry() + private async Task RecreateWebViewAsync() { - if (string.IsNullOrEmpty(_pendingMessage)) + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + if (_recreating) { return; } - ThreadHelper.JoinableTaskFactory.RunAsync(async () => + if (_payload == null) { - await Task.Yield(); - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - TryFlushPendingWebMessage(); - }).FileAndForget("WebComponentUserControl/SchedulePendingMessageRetry"); + return; + } + + if (string.IsNullOrEmpty(_view)) + { + return; + } + + _recreating = true; + try + { + _airspaceGuard?.Dispose(); + _airspaceGuard = null; + + if (webView.Parent is Grid grid) + { + grid.Children.Remove(webView); + try + { + webView.Dispose(); + } + catch (Exception ex) + { + _logger.Error("Failed to dispose WebView2 during recreate.", ex); + } + + var replacement = new WebView2 { Margin = new Thickness(0) }; + grid.Children.Insert(0, replacement); + webView = replacement; + } + + OnThemeChanged(null); + loadingOverlay.Visibility = Visibility.Visible; + _initialized = false; + _webViewInitTask = InitializeWebView2Async(_payload, _view); + + if (IsLoaded) + { + _airspaceGuard = new WebView2AirspaceGuard(this, webView, _logger); + } + } + finally + { + _recreating = false; + } } private void Initialize(T payload, string view) { ThreadHelper.ThrowIfNotOnUIThread(); + _payload = payload; + _view = view; Loaded += OnLoaded; Unloaded += OnUnloaded; OnThemeChanged(null); - _ = InitializeWebView2Async(payload, view); + _webViewInitTask = InitializeWebView2Async(payload, view); VSColorTheme.ThemeChanged += OnThemeChanged; } @@ -325,21 +392,22 @@ private async Task CreatePerWindowEnvAsync(string view) return await CoreWebView2Environment.CreateAsync(userDataFolder: cachePath); } - private async Task InitializeWebView2Async(T payload, string view) + private async Task InitializeWebView2Async(object payload, string view) { var env = await CreatePerWindowEnvAsync(view); await webView.EnsureCoreWebView2Async(env); - webView.CoreWebView2.NavigationStarting += HandleNavigationStarting; + var core = webView.CoreWebView2; + core.NavigationStarting += HandleNavigationStarting; + core.ProcessFailed += HandleProcessFailed; webView.NavigationCompleted += (_, _) => { - loadingOverlay.Visibility = System.Windows.Visibility.Collapsed; - TryFlushPendingWebMessage(); + loadingOverlay.Visibility = Visibility.Collapsed; }; - webView.CoreWebView2.WebMessageReceived += (sender, e) => + core.WebMessageReceived += (_, e) => { _ = OnWebMessageReceivedAsync(e); }; @@ -349,17 +417,41 @@ private async Task InitializeWebView2Async(T payload, string view) var localFolder = Path.Combine(exeFolder, FOLDERLOCATION); _host = GetHost(view); - webView.CoreWebView2.SetVirtualHostNameToFolderMapping(_host, localFolder, CoreWebView2HostResourceAccessKind.Allow); + core.SetVirtualHostNameToFolderMapping(_host, localFolder, CoreWebView2HostResourceAccessKind.Allow); // Generate and inject the initialization script BEFORE navigation // This ensures window.ideContext is set before React components initialize and check for it // AddScriptToExecuteOnDocumentCreatedAsync runs the script before any page scripts execute var initialScript = await GenerateInitialScriptAsync(payload); - await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(initialScript); + await core.AddScriptToExecuteOnDocumentCreatedAsync(initialScript); webView.Source = new Uri($"https://{_host}/index.html"); } + private void HandleProcessFailed(object sender, CoreWebView2ProcessFailedEventArgs e) + { + _logger.Info($"WebView2 process failed: {e.ProcessFailedKind}."); + + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + if (e.ProcessFailedKind == CoreWebView2ProcessFailedKind.BrowserProcessExited) + { + await RecreateWebViewAsync(); + return; + } + + if (e.ProcessFailedKind == CoreWebView2ProcessFailedKind.RenderProcessExited + || e.ProcessFailedKind == CoreWebView2ProcessFailedKind.RenderProcessUnresponsive) + { + _initialized = false; + var core = TryGetCoreWebView2(); + core?.Reload(); + } + }).FileAndForget("WebComponentUserControl/HandleProcessFailed"); + } + /// /// This method enforces navigation rules for URLs: /// @@ -381,6 +473,7 @@ private void HandleNavigationStarting(object sender, CoreWebView2NavigationStart // Allow navigation to our local app host if (!string.IsNullOrEmpty(_host) && uri.Equals($"https://{_host}/index.html", StringComparison.OrdinalIgnoreCase)) { + _initialized = false; return; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 241f6864..d05263d3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -11,7 +11,9 @@ using Codescene.VSExtension.Core.Interfaces.Telemetry; using Codescene.VSExtension.Core.Util; using Codescene.VSExtension.VS2022.Application.ErrorHandling; +using Codescene.VSExtension.VS2022.Application.Services; using Codescene.VSExtension.VS2022.Handlers; +using Codescene.VSExtension.VS2022.Notices; using Codescene.VSExtension.VS2022.Options; using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; using Community.VisualStudio.Toolkit; @@ -39,14 +41,18 @@ namespace Codescene.VSExtension.VS2022; [ProvideToolWindow(typeof(CodeSceneToolWindow.Pane), Style = VsDockStyle.Tabbed, Window = WindowGuids.SolutionExplorer)] public sealed class VS2022Package : ToolkitPackage { + private readonly AsyncManualResetEvent _initialized = new AsyncManualResetEvent(); private ILogger _logger; private SolutionEventsHandler _solutionEventsHandler; private IAsyncTaskScheduler _scheduler; + private IIdeServerClient _ideServerClient; public static VS2022Package Instance { get; private set; } public CancellationToken PackageDisposalToken => DisposalToken; + internal Task WaitForInitializationAsync() => _initialized.WaitAsync(); + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { Instance = this; @@ -69,8 +75,10 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Commands await this.RegisterCommandsAsync(); - // Cli file await CheckCliFileAsync(); + await GetServiceAsync(); + JoinableTaskFactory.RunAsync(() => StartIdeServerAsync(cancellationToken)).FileAndForget("VS2022Package/StartIdeServer"); + JoinableTaskFactory.RunAsync(ShowTelemetryNoticeAsync).FileAndForget("VS2022Package/TelemetryNotice"); // Subscribe on active document change event await SubscribeOnActiveWindowChangeAsync(); @@ -101,6 +109,10 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke JoinableTaskFactory.RunAsync(() => SendTelemetryAsync(CodeSceneConstants.Telemetry.ONACTIVATEEXTENSIONERROR, DisposalToken)).FileAndForget("VS2022Package/SendTelemetry"); System.Diagnostics.Debug.Fail($"VS2022Package.InitializeAsync failed for CodeScene Extension: {e}"); } + finally + { + _initialized.Set(); + } } protected override void Dispose(bool disposing) @@ -111,6 +123,7 @@ protected override void Dispose(bool disposing) UnsubscribeFromGlobalExceptionHandlers(); _solutionEventsHandler?.Dispose(); (_scheduler as IDisposable)?.Dispose(); + _ideServerClient?.Dispose(); } base.Dispose(disposing); @@ -253,6 +266,22 @@ private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledEx } } + private async Task ShowTelemetryNoticeAsync() + { + try + { + var notice = await VS.GetMefServiceAsync(); + if (notice != null) + { + await notice.ShowOnceAsync(); + } + } + catch (Exception ex) + { + _logger?.Error("Failed to show telemetry notice.", ex); + } + } + private async Task CheckCliFileAsync() { var cliFileChecker = await GetServiceAsync(); @@ -262,6 +291,15 @@ private async Task CheckCliFileAsync() } } + private async Task StartIdeServerAsync(CancellationToken cancellationToken) + { + _ideServerClient = await VS.GetMefServiceAsync(); + if (_ideServerClient != null) + { + await _ideServerClient.StartAsync(cancellationToken); + } + } + private async Task SubscribeOnActiveWindowChangeAsync() { await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs index 83493343..13685667 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs @@ -12,7 +12,7 @@ internal sealed partial class Vsix public const string Name = "CodeScene"; public const string Description = @"Integrates CodeScene analysis into Visual Studio. Keeps your code clean and maintainable."; public const string Language = "en-US"; - public const string Version = "0.8.7"; + public const string Version = "0.9.0"; public const string Author = "CodeScene"; public const string Tags = "ai;ai code review;code analysis;code health;code metrics;code review;code quality;technical debt;cyclomatic complexity;refactoring"; public const bool IsPreview = false; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest index 85e7f1c9..7b05bc77 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest @@ -1,7 +1,7 @@ - + CodeScene Integrates CodeScene analysis into Visual Studio. Keeps your code clean and maintainable. Resources\cs-logo.png @@ -9,12 +9,12 @@ ai;ai code review;code analysis;code health;code metrics;code review;code quality;technical debt;cyclomatic complexity;refactoring - + amd64 - + diff --git a/Makefile b/Makefile index a553330c..14c9db16 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ include .github/sha.mk # Lazy-once cache key - computed on first use, then cached for rest of Make invocation CACHE_KEY = $(eval CACHE_KEY := $$(call get_cache_key))$(CACHE_KEY) -.PHONY: test test1 test-mine coverage-mine copy-assets restore format format-all format-check class-size-mine no-regions-mine test-cache test-sha install-cli delta pr-size clean prebuild quit-vs benchmark tag-test-release +.PHONY: test test1 test-mine coverage-mine copy-assets updatecwf restore format format-all format-check class-size-mine no-regions-mine test-cache test-sha install-cli delta pr-size clean prebuild quit-vs benchmark tag-test-release # You might need something like: # export PATH="$PATH:/mnt/c/Program Files/dotnet:/mnt/c/Program Files/Microsoft Visual Studio/18/Community/MSBuild/Current/Bin:/mnt/c/Program Files/Microsoft Visual Studio/18/Community/Common7/IDE/Extensions/TestPlatform" @@ -30,6 +30,9 @@ PROJ_FILES := $(filter-out %_wpftmp.csproj,$(PROJ_FILES_RAW)) cs-cwf.zip: @pwsh.exe .github/cwf.ps1 > cs-cwf.log 2>&1 && del cs-cwf.log || (type cs-cwf.log && del cs-cwf.log && exit /b 1) +updatecwf: + @pwsh.exe -File .github/cwf.ps1 -Local + cs-ide.zip: @pwsh.exe .github/cli.ps1 > cs-ide.log 2>&1 && del cs-ide.log || (type cs-ide.log && del cs-ide.log && exit /b 1) @@ -58,7 +61,7 @@ build: .build-timestamp test: quit-vs build $(call call_cached,$(CACHE_KEY),pwsh.exe -File .github/test.ps1) -# make test1 TEST=GitChangeObserverTests +# make test1 TEST=IdeServerClientTests test1: build $(call call_cached,$(CACHE_KEY),pwsh.exe -File .github/test1.ps1 -TestName $(TEST)) diff --git a/README.md b/README.md index ebecd288..933f191f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ You can find more documentation on its content [here](https://codescene.io/docs/ Location: `.codescene/config.json` +The bundled CLI (since 0.9.0) picks the Git merge base used for delta analysis. See [Baseline resolution](docs/baseline-resolution.md) for the full algorithm, examples, and troubleshooting. + Example configuration: ```json { @@ -68,7 +70,7 @@ Example configuration: ``` | Property | Type | Default | Description | |----------|------|---------|-------------| -| **baseline_branch** | String | origin/HEAD (default branch) | The name of the branch that the Code Health Monitor should compare against when running its delta analysis. If no origin/HEAD or config present, it will look for the nearest merge-base among common shared branches (main, master, develop etc) +| **baseline_branch** | String | Automatic (see [Baseline resolution](docs/baseline-resolution.md)) | Branch name used to resolve the merge base with `HEAD` (remote `origin/` preferred over local). Replaces the default ref list entirely when set. ### Settings ### diff --git a/SETUP.md b/SETUP.md index 60a2f4fc..c57d2010 100644 --- a/SETUP.md +++ b/SETUP.md @@ -39,6 +39,8 @@ The Centralized Webviews Framework (CWF) used by the extension, which provides s **Note:** Access to the CWF repository requires appropriate permissions. +A normal build downloads the latest GitHub release. `make updatecwf` builds the sibling checkout at `../cs-webview` and copies it into the extension instead. + ## Feature Flags This plugin supports simple feature flags that can be controlled at build time using environment variables. @@ -76,11 +78,6 @@ Also make sure to close the IDE before setting the env variable to ensure that t If a property is not provided, it defaults to false. -| Flag name | Description | -|------------------------------|--------------------------------------------------------------------------------------------------------| -| FEATURE_PERIODIC_GIT_SCAN | Enables detailed logging for periodic git status scanning. Disabled by default. | -| FEATURE_INITIAL_GIT_OBSERVER | Enables detailed logging for initial GitChangeObserver invocation on startup. Disabled by default. | - ## License See LICENSE file. diff --git a/docs/baseline-resolution.md b/docs/baseline-resolution.md new file mode 100644 index 00000000..93654666 --- /dev/null +++ b/docs/baseline-resolution.md @@ -0,0 +1,159 @@ +# Code Health Monitor baseline resolution + +Since extension **0.9.0**, the bundled CodeScene CLI chooses the analysis baseline for each Git repository. The Visual Studio extension does not pass a baseline revision on `watchFiles`; the CLI resolves it when watching the repository. + +## What the baseline is + +The Code Health Monitor compares each file against its content at a **merge base** commit. A file appears in the Monitor when it differs from that commit, or when it is untracked (and not ignored by Git). Uncommitted edits count: the working tree is compared to the merge base, not only new commits. + +## How the baseline is selected + +```mermaid +flowchart TD + A[Read optional baseline_branch from .codescene/config.json] --> B{Configured?} + B -->|Yes| C["Candidates: refs/remotes/origin/, then refs/heads/"] + B -->|No| D["Candidates: origin/HEAD target, origin/main, origin/master, main, master"] + C --> E["First ref that git rev-parse --verify accepts"] + D --> E + E --> F{Ref found?} + F -->|No| G["Merge base = HEAD"] + F -->|Yes| H["Merge base = git merge-base HEAD "] + H --> I{Merge base exists?} + I -->|No| G + I -->|Yes| J[Use merge base for delta inventory] +``` + +Steps in order: + +1. Build a list of **candidate refs** from configuration, or use the default list below. +2. Pick the **first** candidate that `git rev-parse --verify ^{commit}` accepts. Only refs already present in the local clone are used; the CLI does not run `git fetch`. +3. Let **HEAD** be the current commit. The baseline commit is `git merge-base HEAD `. +4. If no candidate ref exists, or merge-base returns nothing (no shared history), the baseline commit is **HEAD**. + +**Default candidates** (when `baseline_branch` is not set), in order: + +| Order | Ref | +|------|-----| +| 1 | Target of `refs/remotes/origin/HEAD` (only if that symbolic ref resolves) | +| 2 | `refs/remotes/origin/main` | +| 3 | `refs/remotes/origin/master` | +| 4 | `refs/heads/main` | +| 5 | `refs/heads/master` | + +Duplicate entries are removed. A dangling `origin/HEAD` is skipped; the list continues with `origin/main`, and so on. + +## Configuring the baseline + +Set **`baseline_branch`** in `.codescene/config.json` to a **branch name** (not `origin/develop`, a tag, or a commit SHA): + +```json +{ + "baseline_branch": "develop" +} +``` + +When configured: + +- Candidates are only `refs/remotes/origin/` then `refs/heads/`. The remote-tracking ref wins if both exist. +- The value is used as-is (no trimming). A name with leading or trailing spaces is treated literally. +- The default candidate list is **not** used. If the branch cannot be resolved, the CLI does **not** fall back to `main` or `master`. + +**Error messages** (logged at error level; analysis still runs using HEAD as the merge base): + +- Branch missing: + + `Configured baseline branch missing could not be resolved as any of ["refs/remotes/origin/missing" "refs/heads/missing"] and analysis will fall back to HEAD.` + +- Branch exists but shares no history with HEAD: + + `Configured baseline branch unrelated resolved as refs/heads/unrelated but has no merge base with HEAD; analysis will fall back to HEAD.` + +Invalid JSON in `config.json` is ignored and the default candidate list is used. + +## Fallbacks and edge cases + +| Situation | Merge base used | Typical Monitor effect | +|-----------|-----------------|-------------------------| +| No candidate ref resolves | HEAD | Only uncommitted or untracked changes vs the current commit | +| Candidate ref has no merge base with HEAD | HEAD | Same as above | +| Repository has **no commits** | *(none)* | All tracked and untracked scorable files are included | +| Configured branch missing or unrelated | HEAD | Error logged; see messages above | + +Only the **`origin`** remote is considered (`refs/remotes/origin/...`). Other remotes (for example `upstream`) are not part of the default list. + +Tags do not participate in baseline selection. + +## Examples + +### Feature branch with `origin` and `main` + +- **Setup:** `origin/main` at commit A; local `feature` branched from A with commit B; working tree clean. +- **Chosen ref:** `refs/remotes/origin/main` (or `origin/HEAD` if it points at `main`). +- **Merge base:** A. +- **Monitor:** Files changed in commit B (for example `feature.cs`). + +### Local `main` ahead of `origin/main` + +- **Setup:** `origin/main` at A; local `main` has extra commit C; you work on `feature` from C. +- **Chosen ref:** `refs/remotes/origin/main` (not local `main`). +- **Merge base:** A (not C). +- **Monitor:** Files introduced on local `main` since the remote (C) **and** feature work, because both are after the merge base with `origin/main`. + +### On default branch with unpushed commits + +- **Setup:** Checked out `main`, one commit ahead of `origin/main`, clean tree. +- **Chosen ref:** `refs/remotes/origin/main`. +- **Merge base:** Tip of `origin/main`, not your local `HEAD`. +- **Monitor:** Files touched in the unpushed commit(s). + +### `develop`-based workflow + +- **Without config:** Default list looks for `main` / `master`, not `develop`. If only `develop` exists locally, no candidate matches and merge base becomes **HEAD** — committed work on `feature` may not appear in the Monitor. +- **With `"baseline_branch": "develop"`:** Candidates are `origin/develop` then `develop`; merge base is the branch point with `develop`. + +### Local repository, no remote + +- **Setup:** Only local `main` and `feature`. +- **Chosen ref:** `refs/heads/main`. +- **Monitor:** Files on `feature` not in `main`. + +If the default branch is only `develop` (no `main` / `master`), unconfigured mode falls back to **HEAD**; set `baseline_branch` to `develop`. + +### Dangling `origin/HEAD` + +- **Setup:** `origin/HEAD` points at a missing ref; `origin/main` exists. +- **Chosen ref:** `refs/remotes/origin/main`. +- **Monitor:** Same as a normal `origin/main` baseline. + +### Detached HEAD + +- **Setup:** `git checkout` a specific commit (no branch name). +- **Behavior:** Baseline selection still uses the candidate refs above; `git merge-base` uses the detached **HEAD** commit. The Monitor keeps working; inventory depends on how HEAD relates to the chosen ref. + +### Fork with default branch on `upstream` + +- **Setup:** Your clone only has `origin` (your fork); the real default branch is on `upstream/main`. +- **Unconfigured:** Only `origin/*` and local `main` / `master` are considered. If your fork’s `origin/main` is stale, refresh it with `git fetch origin` or set `baseline_branch` explicitly. + +### Orphan branch or no main-like branch + +- **Setup:** Only branches like `develop` / `feature`, no `main`, `master`, or matching `origin/*` refs. +- **Merge base:** HEAD. +- **Monitor:** Committed files that match HEAD are omitted; uncommitted/untracked changes still appear. + +## Changes since 0.8.x + +- Baseline selection moved from the extension (`GitService.GetBaselineCommit` and `MainBranchMergeBaseSelector`) into the bundled CLI. `watchFiles` does not send a baseline revision. +- For a configured branch, **`origin/` is preferred** over the local branch. Previously the extension resolved the local branch first (`Branches[name]`, then `origin/`). +- The unconfigured fallback list shrank. Previously, a resolved `origin/HEAD` was the only candidate. Otherwise the extension tried `main`, `master`, `develop`, `development`, `trunk`, and `dev`, and kept the merge base closest to `HEAD`. The CLI takes the first existing ref in the default list above (`main` and `master` only, plus `origin/HEAD`, `origin/main`, and `origin/master`). Teams using `develop` or similar must set `baseline_branch` unless `origin/HEAD` already points at that branch. +- On a main-like branch, the baseline used to be **HEAD**, so nothing already committed on that branch was in the Monitor. Now it is the merge base with the chosen ref (usually `origin/main`), so **unpushed commits** are included. The extension also skipped the current branch as a candidate; the CLI does not. +- When no merge base could be resolved, the extension tried the branch reflog (a `created from` entry) and otherwise used an **empty** baseline (baseline file content was skipped). The CLI uses **HEAD** and does not read the reflog. +- `baseline_branch` is **no longer trimmed** in the CLI (the extension previously called `.Trim()`). +- Only the **`origin`** remote is considered for default selection. + +## Troubleshooting + +1. Open the Visual Studio **Output** window and select the **CodeScene** pane. CLI log lines are prefixed with `[cs-ide]`. Look for the error messages quoted in [Configuring the baseline](#configuring-the-baseline). The same lines are written to the extension log under `%LOCALAPPDATA%\Codescene\`. Enable **Show Debug Logs** under [Extension settings](../README.md#settings) when you need the extra debug lines. +2. Run **`git fetch origin`** so `refs/remotes/origin/*` match the remote you care about. +3. Run **`git remote set-head origin --auto`** if `origin/HEAD` is missing or points at a deleted branch. +4. Set **`baseline_branch`** in `.codescene/config.json` to your team’s integration branch when it is not `main` or `master`.