From b45974104f14f346a1abf67c18242c2a8eed23d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 13:11:21 +0200 Subject: [PATCH 01/38] feat: run cs-ide as a persistent JSON-RPC server Keep one native server process for reviews, telemetry, and workspace watches instead of launching cs-ide.exe per command. --- .github/workflows/benchmarks.yml | 17 - .github/workflows/github-release.yml | 2 +- .../BenchmarkSupport.cs | 59 +- .../CachingCodeReviewerBenchmarks.cs | 82 --- ...descene.VSExtension.Core.Benchmarks.csproj | 1 + .../CliExecutor/ReviewContentTests.cs | 4 + ...e.VSExtension.Core.IntegrationTests.csproj | 1 + .../TestSettingsProvider.cs | 2 + ....VSExtension.Core.SubcutaneousTests.csproj | 2 + .../FileWatcherSubcutaneousTests.cs | 86 --- .../GitCommandSubcutaneousTests.cs | 362 ---------- .../GitServiceSubcutaneousTests.cs | 15 +- .../PollingRaceSubcutaneousTests.cs | 53 -- .../RecordingAsyncTaskScheduler.cs | 85 --- .../RecordingGitObservers.cs | 108 --- .../SubcutaneousGitTestInfrastructure.cs | 252 +------ .../TestHarnessServices.cs | 76 +- .../WatchWorkflowSubcutaneousTests.cs | 128 ++++ .../CachingCodeReviewerTestBase.cs | 45 -- .../CancellationTokenTests.cs | 75 -- ...aAsync_ContentUnchanged_SkipsDeltaTests.cs | 181 ----- ...eltaAsync_DelegatesToInnerReviewerTests.cs | 57 -- ...c_DeltaCacheHit_ReturnsCachedDeltaTests.cs | 84 --- ...taAsync_IdenticalScores_SkipsDeltaTests.cs | 163 ----- .../DeltaAsync_InFlightCoalescingTests.cs | 123 ---- ...eltaAsync_NullFilePath_ReturnsNullTests.cs | 97 --- ...taAsync_WithNotifier_CallsNotifierTests.cs | 128 ---- ...ithTelemetryManager_SendsTelemetryTests.cs | 331 --------- ...aselineCacheHit_ReturnsCachedScoreTests.cs | 51 -- ...coreAsync_DelegatesToInnerReviewerTests.cs | 49 -- ...Async_CacheHit_ReturnsCachedReviewTests.cs | 70 -- ...lineAsync_DelegatesToInnerReviewerTests.cs | 48 -- ...validation_CallsInnerReviewerAgainTests.cs | 58 -- ...omCacheWithoutCallingInnerReviewerTests.cs | 53 -- ...ReviewAsync_CacheMiss_CachesResultTests.cs | 53 -- ...CacheMiss_DelegatesToInnerReviewerTests.cs | 57 -- ...sDifferentContent_SeparateCliCallsTests.cs | 68 -- ..._DifferentContent_CausesNewCliCallTests.cs | 57 -- ...DifferentFiles_CachedIndependentlyTests.cs | 56 -- ...gatesToInnerReviewerWithoutCachingTests.cs | 42 -- ...erReviewerReturnsNull_DoesNotCacheTests.cs | 49 -- ...ReviewerThrows_PropagatesExceptionTests.cs | 45 -- ...eConcurrentRequests_OnlyOneCliCallTests.cs | 62 -- ...gatesToInnerReviewerWithoutCachingTests.cs | 42 -- ..._NullPath_DelegatesToInnerReviewerTests.cs | 42 -- ...CaseSensitivity_CacheInconsistencyTests.cs | 61 -- ..._SameContent_UsesCacheOnSecondCallTests.cs | 49 -- ...faultCache_CreatesNewCacheInstanceTests.cs | 42 -- ...eTrue_PassesThroughToInnerReviewerTests.cs | 48 -- ...ewAsync_WithoutLogger_DoesNotThrowTests.cs | 44 -- ..._BaselineCommit_UsesProvidedCommitTests.cs | 77 -- ...sync_NullRawScore_ReturnsNullDeltaTests.cs | 68 -- .../CliCommandProviderTests.cs | 289 -------- .../CliExecutorRefactorDeviceTests.cs | 178 ++--- .../CliExecutorTests.cs | 404 ++--------- .../CliObjectScoreCreatorTests.cs | 65 -- .../Codescene.VSExtension.Core.Tests.csproj | 1 + .../CodesceneFileWatcherTests.cs | 6 +- .../DefaultBranchGateTests.cs | 312 -------- ...ngScheduledExecutorDynamicIntervalTests.cs | 115 --- ...DroppingScheduledExecutorExecutionTests.cs | 191 ----- ...DroppingScheduledExecutorLifecycleTests.cs | 73 -- ...pingScheduledExecutorRaceConditionTests.cs | 196 ----- .../DroppingScheduledExecutorTestBase.cs | 25 - .../FakeLogger.cs | 47 ++ ...ileChangeEventProcessorConcurrencyTests.cs | 294 -------- .../FileChangeEventProcessorTests.cs | 509 ------------- .../FileChangeEventTests.cs | 138 ---- .../FileChangeHandlerDeletionTests.cs | 149 ---- .../FileChangeHandlerTests.cs | 337 --------- .../GitBlobShaTests.cs | 28 + .../GitChangeDetectorChangedFilesTests.cs | 524 -------------- .../GitChangeDetectorExceptionTests.cs | 371 ---------- .../GitChangeDetectorExclusionTests.cs | 97 --- .../GitChangeDetectorMainBranchTests.cs | 296 -------- .../GitChangeDetectorTestBase.cs | 458 ------------ .../GitChangeListerCoverageTests.cs | 296 -------- .../GitChangeListerCpuThrottlingTests.cs | 169 ----- .../GitChangeListerDynamicPeriodTests.cs | 185 ----- .../GitChangeListerEdgeCasesTests.cs | 346 --------- .../GitChangeListerTests.cs | 520 -------------- .../GitChangeObserverCoreTestHelpers.cs | 572 --------------- .../GitChangeObserverCoreTests.cs | 573 --------------- .../GitChangeObserverEventQueueTests.cs | 190 ----- .../GitChangeObserverGitignoreTests.cs | 116 --- .../GitChangeObserverNaturalEventTests.cs | 50 -- .../GitChangeObserverRaceConditionTests.cs | 323 --------- .../GitChangeObserverTestBase.cs | 225 ------ .../GitChangeObserverTrackerTests.cs | 125 ---- .../GitChangeObserverWorkflowTests.cs | 109 --- .../GitIgnoreCacheBustingTests.cs | 458 ------------ .../IdeServerClientTests.cs | 402 +++++++++++ .../MergeBaseFinderTests.cs | 198 ----- .../PerFileRequestQueueTests.cs | 129 ---- .../ProcessExecutorTests.cs | 167 ----- .../ReviewPipelineTests.cs | 458 ++++++++++++ .../ReviewQueueProgressTests.cs | 44 ++ .../RpcJsonNormalizerTests.cs | 44 ++ .../SavedFilesTrackerCoreTests.cs | 223 ------ .../ServerWorkerThreadsTests.cs | 23 + .../TelemetryManagerTests.cs | 86 +-- .../TestableGitChangeLister.cs | 83 --- .../UntrackedFileProcessorTests.cs | 227 ------ .../WorkspaceWatchCoordinatorTests.cs | 169 +++++ .../Cache/Review/DeltaCacheService.cs | 10 + .../Application/Cli/CachingCodeReviewer.cs | 373 ---------- .../Application/Cli/CliCommandProvider.cs | 147 ---- .../Application/Cli/CliExecutor.cs | 231 +++--- .../Application/Cli/CliObjectScoreCreator.cs | 65 -- .../Application/Cli/CliServices.cs | 33 - .../Application/Cli/CliSettingsProvider.cs | 2 +- .../Application/Cli/CodeReviewer.cs | 98 ++- .../Application/Cli/GitBlobSha.cs | 37 + .../Application/Cli/IdeServerClient.cs | 675 ++++++++++++++++++ .../Application/Cli/NativeIdeServerProcess.cs | 113 +++ .../Cli/NativeIdeServerProcessFactory.cs | 41 ++ .../Application/Cli/ProcessExecutor.cs | 258 ------- .../Cli/ReviewPipeline.Notifications.cs | 464 ++++++++++++ .../Application/Cli/ReviewPipeline.cs | 385 ++++++++++ .../Application/Cli/RpcJsonNormalizer.cs | 72 ++ .../Application/Cli/ServerWorkerThreads.cs | 19 + .../Application/Git/DefaultBranchGate.cs | 91 --- .../Application/Git/FileChangeEvent.cs | 19 - .../Git/FileChangeEventProcessor.cs | 273 ------- .../Application/Git/FileChangeHandler.cs | 264 ------- .../Application/Git/GitChangeDetector.cs | 489 ------------- .../Application/Git/GitChangeLister.cs | 606 ---------------- ...GitChangeObserverCore.DefaultBranchGate.cs | 20 - .../GitChangeObserverCore.DetectedFiles.cs | 105 --- .../Application/Git/GitChangeObserverCore.cs | 670 ----------------- .../Application/Git/GitIgnoreWatcher.cs | 84 --- .../Application/Git/MergeBaseFinder.cs | 67 -- .../Application/Git/SavedFilesTrackerCore.cs | 118 --- .../Application/Git/TrackerManager.cs | 101 --- .../Application/Git/UntrackedFileProcessor.cs | 74 -- .../Git/WorkspaceWatchCoordinator.cs | 409 +++++++++++ .../Services/CodeHealthMonitorNotifier.cs | 31 + .../Services/ReviewQueueProgress.cs | 77 ++ .../Application/Telemetry/TelemetryManager.cs | 23 +- .../Util/DroppingScheduledExecutor.cs | 162 ----- .../Application/Util/PerFileRequestQueue.cs | 114 --- .../Codescene.VSExtension.Core.csproj | 14 +- .../Consts/Constants.cs | 1 + .../Consts/WebComponentConstants.cs | 1 + .../Enums/Git/FileChangeType.cs | 11 - .../Interfaces/Cli/ICliCommandProvider.cs | 36 - .../Interfaces/Cli/ICliObjectScoreCreator.cs | 14 - .../Interfaces/Cli/ICliServices.cs | 21 - .../Interfaces/Cli/IIdeServerClient.cs | 62 ++ .../Interfaces/Cli/IIdeServerProcess.cs | 28 + .../Cli/IIdeServerProcessFactory.cs | 9 + .../Interfaces/Cli/IProcessExecutor.cs | 13 - .../Interfaces/Cli/IReviewPipeline.cs | 20 + .../Cli/IReviewPipelineFileAccess.cs | 18 + .../Cli/IReviewPipelinePresentation.cs | 26 + .../Cli/IdeServerErrorDataEventArgs.cs | 16 + .../Interfaces/Extension/ISettingsProvider.cs | 2 + .../Interfaces/Git/IGitChangeLister.cs | 29 - .../Interfaces/Git/ISavedFilesTracker.cs | 15 - .../Git/IWorkspaceWatchCoordinator.cs | 16 + .../Interfaces/Git/IWorkspaceWatchHost.cs | 14 + .../Interfaces/ICodeHealthMonitorNotifier.cs | 4 + .../Models/Cli/Delta/DeltaResponseModel.cs | 6 + .../Models/Cli/Review/CliReviewModel.cs | 3 + .../Models/Cli/Rpc/CheckRulesRequestParams.cs | 15 + .../Models/Cli/Rpc/CheckRulesResponse.cs | 19 + .../Models/Cli/Rpc/DeltaNotification.cs | 19 + .../Models/Cli/Rpc/DeltaRequestParams.cs | 15 + .../Models/Cli/Rpc/PreflightRequestParams.cs | 12 + .../Models/Cli/Rpc/PresentedDelta.cs | 11 + .../Models/Cli/Rpc/PresentedReview.cs | 11 + .../Models/Cli/Rpc/ReviewDocument.cs | 13 + .../Cli/Rpc/ReviewFailedNotification.cs | 17 + .../Models/Cli/Rpc/ReviewFile.cs | 13 + .../Models/Cli/Rpc/ReviewFileParams.cs | 18 + .../Models/Cli/Rpc/ReviewFilesParams.cs | 16 + .../Models/Cli/Rpc/ReviewNotification.cs | 19 + .../Models/Cli/Rpc/ReviewQueue.cs | 13 + .../Models/Cli/Rpc/ReviewSubmission.cs | 17 + .../Models/Cli/Rpc/ServerMetadata.cs | 11 + .../Models/Cli/Rpc/ServerStartEvent.cs | 11 + .../Models/Cli/Rpc/TelemetryRequestParams.cs | 12 + .../Models/Cli/Rpc/WatchFilesParams.cs | 15 + .../Models/Cli/Rpc/WatchInventory.cs | 13 + .../Cli/Rpc/WatchInventoryRequestParams.cs | 12 + .../Models/Git/ChangeDetectionContext.cs | 27 - .../Util/DeltaJobTracker.cs | 25 + .../Util/GitPathDiscovery.cs | 32 + .../Util/RpcPath.cs | 50 ++ .../VisualStudioReviewPipelineFileAccess.cs | 97 +++ .../VisualStudioReviewPipelinePresentation.cs | 125 ++++ .../VisualStudioWorkspaceWatchHost.cs | 95 +++ .../Cli/CachingCodeReviewerProvider.cs | 26 +- .../Application/Git/GitChangeListerService.cs | 28 - .../Application/Git/GitChangeObserver.cs | 166 ----- .../Application/Git/IGitChangeObserver.cs | 28 - .../Application/Git/SavedFilesTracker.cs | 93 --- .../Codescene.VSExtension.VS2022.csproj | 22 +- .../EditorDocumentLifecycleHandler.cs | 12 +- .../Handlers/SolutionEventsHandler.cs | 53 +- .../Options/General.cs | 5 + .../Options/GeneralSettingsProvider.cs | 2 + .../VS2022Package.cs | 12 + Makefile | 2 +- SETUP.md | 5 - 205 files changed, 5060 insertions(+), 18869 deletions(-) delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CachingCodeReviewerBenchmarks.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/FileWatcherSubcutaneousTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitCommandSubcutaneousTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/PollingRaceSubcutaneousTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingAsyncTaskScheduler.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingGitObservers.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/WatchWorkflowSubcutaneousTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CachingCodeReviewerTestBase.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/CancellationTokenTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_ContentUnchanged_SkipsDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DelegatesToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_DeltaCacheHit_ReturnsCachedDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_IdenticalScores_SkipsDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_InFlightCoalescingTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_NullFilePath_ReturnsNullTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithNotifier_CallsNotifierTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/DeltaAsync_WithTelemetryManager_SendsTelemetryTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_BaselineCacheHit_ReturnsCachedScoreTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/GetOrComputeBaselineRawScoreAsync_DelegatesToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_CacheHit_ReturnsCachedReviewTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAndBaselineAsync_DelegatesToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_AfterCacheInvalidation_CallsInnerReviewerAgainTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheHit_ReturnsFromCacheWithoutCallingInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_CachesResultTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_CacheMiss_DelegatesToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_ConcurrentRequestsDifferentContent_SeparateCliCallsTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentContent_CausesNewCliCallTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_DifferentFiles_CachedIndependentlyTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_EmptyContent_DelegatesToInnerReviewerWithoutCachingTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerReturnsNull_DoesNotCacheTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_InnerReviewerThrows_PropagatesExceptionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_MultipleConcurrentRequests_OnlyOneCliCallTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullContent_DelegatesToInnerReviewerWithoutCachingTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_NullPath_DelegatesToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_PathCaseSensitivity_CacheInconsistencyTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_SameContent_UsesCacheOnSecondCallTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithDefaultCache_CreatesNewCacheInstanceTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithIsBaselineTrue_PassesThroughToInnerReviewerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewAsync_WithoutLogger_DoesNotThrowTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_BaselineCommit_UsesProvidedCommitTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CachingCodeReviewerTests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliCommandProviderTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliObjectScoreCreatorTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DefaultBranchGateTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorDynamicIntervalTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorExecutionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorLifecycleTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorRaceConditionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DroppingScheduledExecutorTestBase.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FakeLogger.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorConcurrencyTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventProcessorTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeEventTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerDeletionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/FileChangeHandlerTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitBlobShaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorChangedFilesTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExceptionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorExclusionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMainBranchTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorTestBase.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCoverageTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerCpuThrottlingTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerDynamicPeriodTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerEdgeCasesTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeListerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTestHelpers.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverCoreTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverEventQueueTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverGitignoreTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverNaturalEventTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverRaceConditionTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTestBase.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverTrackerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeObserverWorkflowTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitIgnoreCacheBustingTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MergeBaseFinderTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PerFileRequestQueueTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ProcessExecutorTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcJsonNormalizerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/SavedFilesTrackerCoreTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ServerWorkerThreadsTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TestableGitChangeLister.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/UntrackedFileProcessorTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CachingCodeReviewer.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliCommandProvider.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliObjectScoreCreator.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliServices.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/GitBlobSha.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcess.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/NativeIdeServerProcessFactory.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ProcessExecutor.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/RpcJsonNormalizer.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ServerWorkerThreads.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/DefaultBranchGate.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEvent.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeEventProcessor.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FileChangeHandler.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeDetector.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeLister.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DefaultBranchGate.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.DetectedFiles.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitChangeObserverCore.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/GitIgnoreWatcher.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MergeBaseFinder.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/SavedFilesTrackerCore.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/TrackerManager.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/UntrackedFileProcessor.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DroppingScheduledExecutor.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/PerFileRequestQueue.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Enums/Git/FileChangeType.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliCommandProvider.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliObjectScoreCreator.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/ICliServices.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcess.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerProcessFactory.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IProcessExecutor.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelineFileAccess.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipelinePresentation.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IdeServerErrorDataEventArgs.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IGitChangeLister.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/ISavedFilesTracker.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchHost.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesRequestParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/CheckRulesResponse.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PreflightRequestParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedDelta.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/PresentedReview.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewDocument.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFile.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFileParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFilesParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewSubmission.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerMetadata.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ServerStartEvent.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/TelemetryRequestParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchFilesParams.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventory.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/WatchInventoryRequestParams.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Git/ChangeDetectionContext.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelineFileAccess.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeListerService.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/GitChangeObserver.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/IGitChangeObserver.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/SavedFilesTracker.cs 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 427f244a..4afd58d8 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..acce74ba 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs @@ -1,7 +1,5 @@ // 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; @@ -9,8 +7,6 @@ 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 +18,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 +33,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,7 +46,8 @@ public CliExecutor CreateCliExecutor() { return new CliExecutor( _logger, - new BenchmarkCliServices(_commandProvider, _processExecutor, _cacheStorageService), + _ideServerClient, + _cacheStorageService, _settingsProvider, null); } @@ -69,19 +64,6 @@ public CodeReviewer CreateCodeReviewer(IGitService gitService, IPreflightManager 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 +72,7 @@ public void Dispose() } _disposed = true; + _ideServerClient.Dispose(); try { Directory.Delete(RootDirectory, true); @@ -98,20 +81,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 +122,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,6 +173,8 @@ internal sealed class FixedSettingsProvider : ISettingsProvider public bool ShowDebugLogs => false; public string AuthToken => string.Empty; + + public int ServerWorkerThreads => 0; } internal sealed class FixedGitService : IGitService 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/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/CliExecutor/ReviewContentTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs index 0a219363..92e7acb5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs @@ -36,6 +36,10 @@ public int Add(int a, int b) 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"); + + var second = await cliExecutor.ReviewContentAsync(filePath, content); + Assert.IsNotNull(second, "Repeated reviews should reuse the persistent server"); + Assert.IsFalse(string.IsNullOrEmpty(second.RawScore)); } finally { 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..46293e23 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 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..6cf76cec 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,7 @@ internal class TestSettingsProvider : ISettingsProvider public bool ShowDebugLogs => Mock.Object.ShowDebugLogs; public string AuthToken => Mock.Object.AuthToken; + + public int ServerWorkerThreads => Mock.Object.ServerWorkerThreads; } } 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..3545230b 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 @@ -16,6 +17,7 @@ + all 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 index f71a2b6d..2bd8a5af 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs @@ -29,6 +29,7 @@ public void TestMethod(string t1) 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) { } } } } } } } }"; @@ -44,13 +45,12 @@ public void TestMethod(string t1) 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) { } } } } } + if (true) { if (true) { if (true) { if (true) { if (true) { if (true) { if (true) { } } } } } } } + if (t1 == t2) { if (t2 == t3) { if (t3 == t4) { if (t4 == t5) { Console.WriteLine(t1); } } } } } } }"; - protected override bool AutoStartObserver => false; - [TestMethod] public async Task GetFileContentForCommit_OnFeatureBranchBasedOnDevelop_UsesDevelopBaseline() { @@ -136,7 +136,7 @@ public async Task ReviewWithDeltaAsync_OnFeatureBranchBasedOnDevelop_UsesDevelop CommitAll("Improve baseline file on develop"); var developReview = await CodeReviewer.ReviewAsync(absolutePath, DevelopContent); - Assert.AreEqual(9.68m, RoundScore(developReview.Score)); + Assert.IsLessThan(10.00m, RoundScore(developReview.Score)); CheckoutBranch("feature/test-suppression2", create: true); await WriteWorkingFileAsync(relativePath, FeatureContent); @@ -146,11 +146,10 @@ public async Task ReviewWithDeltaAsync_OnFeatureBranchBasedOnDevelop_UsesDevelop 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)); + Assert.AreEqual(RoundScore(developReview.Score), RoundScore(delta.OldScore)); + Assert.AreEqual(RoundScore(featureReview.Score), RoundScore(delta.NewScore)); + Assert.IsLessThan(RoundScore(developReview.Score), RoundScore(featureReview.Score)); } private static decimal RoundScore(float score) 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/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..61684ab7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs @@ -1,12 +1,10 @@ // 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; @@ -14,26 +12,16 @@ namespace Codescene.VSExtension.Core.SubcutaneousTests; public abstract class SubcutaneousGitTestBase { + private IdeServerClient? _ideServerClient; + protected EventJournal Journal { get; private set; } = null!; 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!; @@ -42,26 +30,16 @@ public abstract class SubcutaneousGitTestBase 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")); @@ -86,49 +64,15 @@ public virtual async Task InitializeAsync() 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(); - } + CodeReviewer = new RecordingCodeReviewer(innerReviewer, Journal); + 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(); + _ideServerClient?.Dispose(); if (!string.IsNullOrEmpty(RepositoryRoot) && Directory.Exists(RepositoryRoot)) { @@ -145,6 +89,7 @@ public virtual async Task CleanupAsync() CacheGeneration.Reset(); DeltaJobTracker.Clear(); + return Task.CompletedTask; } protected string AbsolutePath(string relativePath) @@ -152,15 +97,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 +107,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 +136,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 +152,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); @@ -398,26 +187,9 @@ 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!; + var cacheStorage = new TestCacheStorageService(CacheDirectory); + _ideServerClient = new IdeServerClient(cliSettingsProvider, Logger, settingsProvider); + return new CliExecutor(Logger, _ideServerClient, cacheStorage, settingsProvider, null); } private void InitializeGitRepository() diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs index dcca3580..9a490bfd 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,6 @@ public sealed class TestSettingsProvider : ISettingsProvider public bool ShowDebugLogs => false; public string AuthToken => string.Empty; + + public int ServerWorkerThreads => 0; } 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/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..57918efa 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs @@ -11,10 +11,7 @@ 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 { @@ -26,9 +23,7 @@ public class CliExecutorTests 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,58 +34,38 @@ 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, - _mockSettingsProvider.Object, - _lazyTelemetryManager); + _cliExecutor = CreateExecutor(); } [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 expectedReview = new CliReviewModel { Score = 7.5f, RawScore = "base64encoded" }; + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedReview); 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); + _mockClient.Verify( + x => x.ReviewAsync( + It.Is(r => r.FilePath == TestFilePath && r.FileContent == TestFileContent && r.CachePath == TestCachePath), + It.IsAny()), + Times.Once); } [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsDevtoolsException_ThrowsException() + public async Task ReviewContentAsync_WhenClientThrowsDevtoolsException_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())) + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new DevtoolsException("CLI error", 500, "trace-123")); var exception = await Assert.ThrowsAsync(() => @@ -101,14 +76,11 @@ public async Task ReviewContentAsync_WhenProcessExecutorThrowsDevtoolsException_ } [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsRefactoringCreditsDevtoolsException_LogsWarningNotError() + public async Task ReviewContentAsync_WhenClientThrowsRefactoringCreditsDevtoolsException_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())) + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new DevtoolsException(creditsMessage, 402, "trace-xyz")); var exception = await Assert.ThrowsAsync(() => @@ -121,12 +93,9 @@ public async Task ReviewContentAsync_WhenProcessExecutorThrowsRefactoringCredits } [TestMethod] - public async Task ReviewContentAsync_WhenProcessExecutorThrowsGenericException_ReturnsNull() + public async Task ReviewContentAsync_WhenClientThrowsGenericException_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())) + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Generic error")); var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); @@ -135,13 +104,11 @@ public async Task ReviewContentAsync_WhenProcessExecutorThrowsGenericException_R } [TestMethod] - public async Task ReviewContentAsync_WithInvalidJson_ReturnsNull() + public async Task ReviewContentAsync_WhenClientReturnsNull_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"); + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CliReviewModel)null); + var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); Assert.IsNull(result); @@ -150,21 +117,17 @@ public async Task ReviewContentAsync_WithInvalidJson_ReturnsNull() [TestMethod] public async Task ReviewDeltaAsync_WithValidResponse_ReturnsDeltaResponseModel() { - var oldScore = "old-score-b64"; - var newScore = "new-score-b64"; - var expectedDelta = new DeltaResponseModel + var expectedDelta = new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m, ScoreChange = 1.5m }; + _mockClient.Setup(x => x.DeltaAsync("old-score-b64", "new-score-b64", It.IsAny())) + .ReturnsAsync(expectedDelta); + + var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { - 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 }); + OldScore = "old-score-b64", + NewScore = "new-score-b64", + FilePath = TestFilePath, + FileContent = TestFileContent, + }); Assert.IsNotNull(result); Assert.AreEqual(expectedDelta.NewScore, result.NewScore); @@ -173,35 +136,9 @@ public async Task ReviewDeltaAsync_WithValidResponse_ReturnsDeltaResponseModel() } [TestMethod] - public async Task ReviewDeltaAsync_WithEmptyArguments_ReturnsNull() + public async Task ReviewDeltaAsync_WhenClientThrowsException_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())) + _mockClient.Setup(x => x.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Error")); var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest { OldScore = "old", NewScore = "new" }); @@ -213,16 +150,9 @@ public async Task ReviewDeltaAsync_WhenProcessExecutorThrowsException_ReturnsNul [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 +162,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 +174,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,7 +205,8 @@ public void Constructor_WithNullTelemetryManagerLazy_DoesNotThrow() { var executor = new CliExecutor( _mockLogger.Object, - _mockCliServices.Object, + _mockClient.Object, + _mockCacheStorage.Object, _mockSettingsProvider.Object); Assert.IsNotNull(executor); @@ -301,12 +215,9 @@ public void Constructor_WithNullTelemetryManagerLazy_DoesNotThrow() [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, __) => + var completion = new TaskCompletionSource(); + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .Returns((_, ct) => { ct.Register(() => completion.TrySetCanceled(ct)); return completion.Task; @@ -324,13 +235,10 @@ public async Task ReviewContentAsync_WhenCancelled_ReturnsNull() [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 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, _) => + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .Returns((_, ct) => { callCount++; if (callCount == 1) @@ -338,7 +246,7 @@ public async Task ReviewContentAsync_WhenSecondCallCancelsFirst_FirstReturnsNull return firstCompletion.Task; } - return Task.FromResult(JsonConvert.SerializeObject(new CliReviewModel { Score = 7.5f, RawScore = "raw" })); + return Task.FromResult(new CliReviewModel { Score = 7.5f, RawScore = "raw" }); }); var firstTask = _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent, false); @@ -362,16 +270,13 @@ public async Task ReviewContentAsync_ConcurrentDifferentFiles_UsesBoundedConcurr 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, _, _, __) => + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .Returns(async (request, _) => { Interlocked.Increment(ref callCount); startedSignal.TrySetResult(true); await completion.Task; - return JsonConvert.SerializeObject(new CliReviewModel { Score = 7.5f, RawScore = payload }); + return new CliReviewModel { Score = 7.5f, RawScore = request.FilePath }; }); var firstTask = _cliExecutor.ReviewContentAsync(firstFile, TestFileContent); @@ -390,93 +295,6 @@ public async Task ReviewContentAsync_ConcurrentDifferentFiles_UsesBoundedConcurr 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() { @@ -487,69 +305,6 @@ public void GetReviewCancellationPathIdentity_WhenGetFullPathThrows_ReturnsOrigi 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() { @@ -570,19 +325,9 @@ public async Task ExecuteOnChannelAsync_AcquiresSemaphoreBeforeWaitingForCpu() 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 executor = CreateExecutor(mockThrottler.Object, 1); + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CliReviewModel { Score = 1 }); var task1 = executor.ReviewContentAsync("/file1.cs", "content1"); var task2 = executor.ReviewContentAsync("/file2.cs", "content2"); @@ -625,20 +370,9 @@ public async Task ReviewDeltaAsync_AcquiresSemaphoreBeforeWaitingForCpu() 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 executor = CreateExecutor(mockThrottler.Object, 1); + _mockClient.Setup(x => x.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m }); 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" }); @@ -660,5 +394,17 @@ public async Task ReviewDeltaAsync_AcquiresSemaphoreBeforeWaitingForCpu() cpuCheckSignal.SetResult(true); await Task.WhenAll(task1, task2); } + + private CliExecutor CreateExecutor(ICpuUsageThrottler throttler = null, int concurrency = 1) + { + return new CliExecutor( + _mockLogger.Object, + _mockClient.Object, + _mockCacheStorage.Object, + _mockSettingsProvider.Object, + _lazyTelemetryManager, + throttler, + concurrency); + } } } 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/Codescene.VSExtension.Core.Tests.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/Codescene.VSExtension.Core.Tests.csproj index 721bfbe8..7225bc89 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 @@ -6,6 +6,7 @@ enable enable true + $(NoWarn);VSTHRD002;VSTHRD003;VSTHRD103;VSTHRD110;VSTHRD200;CS8602;CS8604 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/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/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/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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs new file mode 100644 index 00000000..d2ea6b47 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -0,0 +1,402 @@ +// 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 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 ReviewAsync_NormalizesCamelCaseResponse() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var review = await harness.Client.ReviewAsync(new ReviewRequestModel + { + FilePath = "src/a.cs", + FileContent = "class A {}", + }); + + Assert.AreEqual("raw", review.RawScore); + Assert.AreEqual(9.68f, review.Score); + Assert.AreEqual("review-sha", review.GitBlobSha); + } + + [TestMethod] + public async Task RepeatedReviews_UseSingleProcess() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + await harness.Client.ReviewAsync(new ReviewRequestModel { FilePath = "src/a.cs", FileContent = "class A {}" }); + await harness.Client.ReviewAsync(new ReviewRequestModel { FilePath = "src/b.cs", FileContent = "class B {}" }); + + Assert.AreEqual(1, harness.Factory.StartCount); + } + + [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 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", inventory.RepoRoot); + Assert.AreEqual("watched.ts", inventory.Files[0]); + Assert.IsNull(review.Id); + Assert.AreEqual("watched.ts", 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", review.Id); + Assert.AreEqual("src/a.ts", review.Path); + Assert.AreEqual(GitBlobSha.FromUtf8("hello"), review.Result.GitBlobSha); + Assert.AreEqual(2, queue.Count); + } + + [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); + } + + 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, JsonRpc serverRpc, Stream serverStream) + { + Client = client; + Factory = factory; + ServerRpc = serverRpc; + ServerStream = serverStream; + } + + public IdeServerClient Client { get; } + + public FakeProcessFactory Factory { get; } + + public JsonRpc ServerRpc { get; } + + public Stream ServerStream { 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)); + serverRpc.AddLocalRpcTarget(new FixtureServer(serverRpc)); + serverRpc.StartListening(); + var client = new IdeServerClient(settings.Object, logger.Object, factory, userSettings.Object, TimeSpan.FromSeconds(5)); + var harness = new IdeServerTestHarness(client, factory, serverRpc, serverStream); + _ = 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; + } + + [JsonRpcMethod("cs-ide/review", UseSingleObjectParameterDeserialization = true)] + public JObject Review(JObject request) + { + return JObject.Parse(@"{""fileLevelCodeSmells"":[],""functionLevelCodeSmells"":[],""rawScore"":""raw"",""score"":9.68,""gitBlobSha"":""review-sha""}"); + } + + [JsonRpcMethod("cs-ide/device-id", UseSingleObjectParameterDeserialization = true)] + public JObject DeviceId(JObject request) + { + return JObject.Parse(@"{""deviceId"":""device-42""}"); + } + + [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") + { + var failed = new JObject + { + ["id"] = null, + ["path"] = relPath, + ["repoRoot"] = repoRoot, + ["message"] = "fixture review failed", + }; + await _rpc.NotifyAsync("cs-ide/reviewFailed", failed); + continue; + } + + var payload = new JObject + { + ["path"] = relPath, + ["repoRoot"] = repoRoot, + ["result"] = new JObject + { + ["rawScore"] = "raw", + ["score"] = 9.68, + ["gitBlobSha"] = GitBlobSha.FromUtf8(content), + }, + ["queue"] = new JObject + { + ["count"] = 2, + ["files"] = new JArray("b.ts", "c.ts"), + }, + }; + 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 Kill() + { + HasExited = true; + Exited?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + Kill(); + } + } +} 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/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/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/ReviewPipelineTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs new file mode 100644 index 00000000..6aed4ee4 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -0,0 +1,458 @@ +// 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 PresentationEvents _events; + private List<(string RepoRoot, IReadOnlyList Files)> _batches; + private int _nextId; + private ReviewPipeline _pipeline; + + [TestInitialize] + public void Setup() + { + _client = new Mock(); + _events = new PresentationEvents(); + _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 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_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); + } + + 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"); + } + + private sealed class PresentationEvents : 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/ReviewQueueProgressTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs new file mode 100644 index 00000000..7fa097e0 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs @@ -0,0 +1,44 @@ +// 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] + public class ReviewQueueProgressTests + { + [TestMethod] + public void Update_ZeroCount_FlushesImmediately() + { + var snapshots = new List(); + using (var progress = new ReviewQueueProgress(snapshots.Add)) + { + progress.Update(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); + progress.Update(new ReviewQueue { Count = 0, Files = Array.Empty() }); + } + + Assert.IsTrue(snapshots.Exists(queue => queue.Count == 0)); + } + + [TestMethod] + public void ApplyQueue_MapsRunningAndQueuedJobs() + { + DeltaJobTracker.Clear(); + var notifier = new CodeHealthMonitorNotifier(); + notifier.ApplyQueue(new ReviewQueue + { + Count = 2, + 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); + DeltaJobTracker.Clear(); + } + } +} 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/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/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..ae9e2dec 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,17 +24,17 @@ 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(); @@ -51,18 +51,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 +67,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 +76,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 +140,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 +149,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/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/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs new file mode 100644 index 00000000..591c776e --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -0,0 +1,169 @@ +// 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 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 void ApplyInventory_PrunesMonitorAndPreservesDirtyBuffers() + { + var client = new Mock(); + var pipeline = new Mock(); + var host = new Mock(); + ISet keep = null; + host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + { + new ReviewDocument { FilePath = @"C:\repo\dirty.cs", 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)) + { + client.Raise( + x => x.WatchInventoryChanged += null, + client.Object, + new WatchInventory { RepoRoot = @"C:\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))); + } + + [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); + } + 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); + } + } + } + + 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/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/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.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs index 6f0273ad..e2ad4085 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs @@ -22,8 +22,6 @@ 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 @@ -33,7 +31,8 @@ namespace Codescene.VSExtension.Core.Application.Cli public 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(); @@ -45,24 +44,27 @@ public class CliExecutor : ICliExecutor [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) + : this(logger, client, cacheStorage, settingsProvider, telemetryManagerLazy, cpuUsageThrottler, 1) { } internal CliExecutor( ILogger logger, - ICliServices cliServices, + IIdeServerClient client, + ICacheStorageService cacheStorage, ISettingsProvider settingsProvider, Lazy telemetryManagerLazy, ICpuUsageThrottler cpuUsageThrottler, int cliCommandConcurrencyLimit) { _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(); @@ -70,21 +72,13 @@ internal CliExecutor( _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) => + _inFlightReviewCancellation.AddOrUpdate(key, cts, (_, existing) => { try { @@ -92,7 +86,6 @@ public async Task ReviewContentAsync(string filePath, string con } catch (ObjectDisposedException) { - // ignored } existing.Dispose(); @@ -101,16 +94,19 @@ public async Task ReviewContentAsync(string filePath, string con try { - var command = _cliServices.CommandProvider.ReviewFileContentCommand; - var payload = _cliServices.CommandProvider.GetReviewFileContentPayload(filePath, content, _cliServices.CacheStorage.GetSolutionReviewCacheLocation()); - var workingDirectory = GetCliWorkingDirectoryForFile(filePath); + var request = new ReviewRequestModel + { + FilePath = filePath, + FileContent = content, + CachePath = _cacheStorage.GetSolutionReviewCacheLocation(), + }; var (result, elapsedMs) = await ExecuteOnChannelAsync( _cliCommandChannel, cts.Token, - () => ExecuteWithTimingAndLoggingAsync( + () => ExecuteWithTimingAndLoggingAsync( "CLI file review", - () => _cliServices.ProcessExecutor.ExecuteAsync(command, payload, null, cts.Token, workingDirectory), + () => _client.ReviewAsync(request, cts.Token), $"Review of file {fileName} failed")); if (result != null) @@ -150,22 +146,13 @@ public async Task ReviewContentAsync(string filePath, string con 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( + var (result, elapsedMs) = await ExecuteWithTimingAndLoggingAsync( "CLI file delta review", - () => _cliServices.ProcessExecutor.ExecuteAsync(Titles.DELTA, arguments, null, cancellationToken, workingDirectory), + () => _client.DeltaAsync(request.OldScore, request.NewScore, cancellationToken), "Delta for file failed."); if (result != null && !string.IsNullOrEmpty(request.FilePath)) @@ -197,16 +184,9 @@ public async Task ReviewDeltaAsync(ReviewDeltaRequest reques 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 +206,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) @@ -262,7 +248,14 @@ public async Task> FnsToRefactorFromCodeSmellsAsync(str 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), + createRequest: cachePath => new FnsToRefactorCodeSmellRequestModel + { + FileName = fileName, + FileContent = fileContent, + CachePath = cachePath, + Preflight = preflight, + CodeSmells = codeSmells, + }, operationLabel: "ACE refactoring functions from code smells check", cancellationToken: cancellationToken); } @@ -272,25 +265,34 @@ public async Task> FnsToRefactorFromDeltaAsync(string f 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), + createRequest: cachePath => new FnsToRefactorDeltaRequestModel + { + FileName = fileName, + FileContent = fileContent, + CachePath = cachePath, + Preflight = preflight, + DeltaResult = deltaResult, + }, operationLabel: "ACE refactoring functions from delta check", cancellationToken: cancellationToken); } - public async Task GetDeviceIdAsync(CancellationToken cancellationToken = default) + public 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); } @@ -310,12 +312,15 @@ private static string GetReviewCancellationPathIdentity(string filePath) 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 +358,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 +370,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,24 +392,22 @@ 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; @@ -451,7 +448,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 +459,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 +484,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/CliSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs index e172c90f..ed2abab9 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs @@ -13,7 +13,7 @@ public class CliSettingsProvider : ICliSettingsProvider { // single point of truth for CLI version // used by the build pipeline to bundle the CLI with the extension - public string RequiredDevToolVersion => "8a7257420cc2dec1cf6ff7866db4da8c58f67602"; // 1.0.51 + public string RequiredDevToolVersion => "8a7257420cc2dec1cf6ff7866db4da8c58f67602"; public string RequiredCliBinarySha256 => "41b68ec72e3f50ba58b5292608d21b9d51670a5e85105868a6fcaa75a3883c02"; 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..866ad420 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs @@ -13,6 +13,8 @@ using Codescene.VSExtension.Core.Interfaces.Telemetry; 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 { @@ -25,6 +27,7 @@ public class CodeReviewer : ICodeReviewer private readonly IGitService _git; private readonly ICodeHealthMonitorNotifier _notifier; private readonly IPreflightManager _preflightManager; + private readonly IReviewPipeline _pipeline; public CodeReviewer( ILogger logger, @@ -33,7 +36,8 @@ public CodeReviewer( ITelemetryManager telemetryManager, IGitService git, ICodeHealthMonitorNotifier notifier = null, - IPreflightManager preflightManager = null) + IPreflightManager preflightManager = null, + IReviewPipeline pipeline = null) { _logger = logger; _mapper = mapper; @@ -42,6 +46,7 @@ public CodeReviewer( _git = git; _notifier = notifier; _preflightManager = preflightManager; + _pipeline = pipeline; } public async Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) @@ -73,6 +78,11 @@ public async Task ReviewAsync(string path, string content, bool public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) { + if (_pipeline != null) + { + return await ReviewWithPipelineAsync(path, content, cancellationToken); + } + var (review, baselineRawScore) = await ReviewAndBaselineAsync(path, content, operationGeneration, cancellationToken, baselineCommit); if (review?.RawScore == null) { @@ -102,28 +112,7 @@ public async Task DeltaAsync(FileReviewModel review, string 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 delta; + return await EnrichDeltaAsync(path, currentCode, delta, cancellationToken); } catch (OperationCanceledException) { @@ -150,6 +139,69 @@ public async Task GetOrComputeBaselineRawScoreAsync(string path, string return await GetOrComputeBaselineRawScoreInternalAsync(path, oldCode, operationGeneration, cancellationToken); } + 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 pipeline review for '{path}'. Repository root was not found."); + return (null, null); + } + + 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 = true, + }); + + if (cliReview == null) + { + return (null, null); + } + + var review = _mapper.Map(path, cliReview); + if (review?.RawScore == null) + { + return (review, null); + } + + var enriched = await EnrichDeltaAsync(path, content, delta, cancellationToken); + return (review, enriched); + } + + private async Task EnrichDeltaAsync(string path, string currentCode, DeltaResponseModel delta, CancellationToken cancellationToken) + { + if (_preflightManager == null || delta == 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 delta; + } + private async Task GetOrComputeBaselineRawScoreInternalAsync(string path, string oldCode, long? operationGeneration = null, CancellationToken cancellationToken = default) { var baselineCache = new BaselineReviewCacheService(); 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.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs new file mode 100644 index 00000000..4a5bce9d --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -0,0 +1,675 @@ +// 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.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 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 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 TaskCompletionSource _pendingStart; + private JsonRpc _rpc; + private IIdeServerProcess _process; + private int _startCount; + 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) + { + StopProcess(new Exception("CodeScene IDE server restarted")); + await StartAsync(cancellationToken).ConfigureAwait(false); + } + + public Task ReviewAsync(ReviewRequestModel request, CancellationToken cancellationToken = default) + { + return SendRequestAsync("cs-ide/review", request, cancellationToken); + } + + public Task DeltaAsync(string oldScore, string newScore, CancellationToken cancellationToken = default) + { + return SendRequestAsync( + "cs-ide/delta", + new DeltaRequestParams { OldScore = oldScore, NewScore = newScore }, + cancellationToken); + } + + 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(), + }; + _ = SendNotificationAsync("cs-ide/reviewFiles", payload); + } + + public void WatchFiles(string repoRoot, IReadOnlyList relativePaths = null) + { + var payload = new WatchFilesParams + { + RepoRoot = repoRoot, + RelativePaths = relativePaths?.ToArray(), + }; + _logger.Debug($"[cs-ide] sending watchFiles repo-root={repoRoot} relative-paths={(relativePaths == null ? "(whole repository)" : string.Join(", ", relativePaths))}"); + _ = SendNotificationAsync("cs-ide/watchFiles", payload); + } + + public void StopWatchFiles(string repoRoot, IReadOnlyList relativePaths = null) + { + if (_rpc == null) + { + return; + } + + var payload = new WatchFilesParams + { + RepoRoot = repoRoot, + RelativePaths = relativePaths?.ToArray(), + }; + _ = SendNotificationAsync("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 = _processFactory.Start(cliFilePath, arguments); + process.ErrorDataReceived += (_, e) => + { + var trimmed = (e.Data ?? string.Empty).Trim(); + if (trimmed.Length > 0) + { + _logger.Debug("[cs-ide] " + trimmed); + } + }; + process.BeginErrorReadLine(); + process.Exited += (_, __) => HandleProcessFailure(new Exception("cs-ide server exited"), process); + + 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 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) + { + 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) + { + throw new DevtoolsException(ex.Message, ex.ErrorCode, null); + } + } + + 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) + { + 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 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; + ServerStarted?.Invoke(this, new ServerStartEvent { Metadata = parsed, Restart = restart }); + } + + private void HandleReview(JObject notification) + { + var identity = ParseIdentity(notification); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + ReviewReceived?.Invoke(this, new ReviewNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Result = normalized["result"]?.ToObject(Serializer), + Queue = queue, + }); + } + + private void HandleDelta(JObject notification) + { + var identity = ParseIdentity(notification); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + var resultToken = normalized["result"]; + DeltaReceived?.Invoke(this, new DeltaNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Result = resultToken == null || resultToken.Type == JTokenType.Null + ? null + : resultToken.ToObject(Serializer), + Queue = queue, + }); + } + + private void HandleReviewFailure(JObject notification) + { + var identity = ParseIdentity(notification); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + ReviewFailed?.Invoke(this, new ReviewFailedNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Message = (string)normalized["message"], + Queue = queue, + }); + } + + private void HandleWatchInventory(JObject notification) + { + var inventory = ParseWatchInventory(notification, null); + if (string.IsNullOrEmpty(inventory.RepoRoot)) + { + return; + } + + _logger.Info($"[cs-ide] received watchInventoryChanged repo={inventory.RepoRoot} count={inventory.Files.Count}"); + WatchInventoryChanged?.Invoke(this, inventory); + } + + private Tuple ParseIdentity(JObject notification) + { + if (notification == null) + { + 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)) + { + 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 ParseQueue(JObject notification, string repoRoot) + { + var queueToken = notification?["queue"] as JObject; + if (queueToken == null) + { + return null; + } + + var files = queueToken["files"] as JArray; + return new ReviewQueue + { + Count = queueToken["count"]?.Value() ?? 0, + Files = (files ?? new JArray()) + .Select(token => (string)token) + .Where(value => !string.IsNullOrEmpty(value)) + .Select(relPath => Path.Combine(repoRoot ?? string.Empty, relPath.Replace('/', Path.DirectorySeparatorChar))) + .ToList(), + }; + } + + private void EmitQueue(ReviewQueue queue) + { + if (queue == null) + { + return; + } + + QueueChanged?.Invoke(this, queue); + } + + 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/watchInventoryChanged", UseSingleObjectParameterDeserialization = true)] + public void OnWatchInventoryChanged(JObject notification) => _client.HandleWatchInventory(notification); + } + } +} 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..2f599dc4 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -0,0 +1,464 @@ +// 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)) + { + _ = 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 id={notification.Id} path={notification.Path} repo={notification.RepoRoot}"); + IgnorePending(pending); + return; + } + + pending.ReviewResult = notification.Result; + _presentation.PresentReview(ToPresentedReview(pending, notification.Result)); + TryComplete(pending); + } + } + + private void OnDeltaReceived(object sender, DeltaNotification notification) + { + if (notification == null) + { + return; + } + + if (string.IsNullOrEmpty(notification.Id)) + { + _ = PresentWatchDeltaAsync(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; + _presentation.PresentDelta(ToPresentedDelta(pending, notification.Result)); + } + else + { + _logger?.Warn($"[pipeline] ignoring deltaReview id={notification.Id} path={notification.Path} repo={notification.RepoRoot}"); + } + + 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) + { + var presented = await WatchPresentationAsync(notification.RepoRoot, notification.Path, notification.Result?.GitBlobSha).ConfigureAwait(false); + if (presented == null) + { + return; + } + + lock (_gate) + { + 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) + { + var presented = await WatchPresentationAsync(notification.RepoRoot, notification.Path, notification.Result?.NewGitBlobSha).ConfigureAwait(false); + if (presented == null) + { + 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(repoRoot, posixPath); + var document = _fileAccess.FindOpenDocument(filePath); + if (document == null) + { + try + { + document = await _fileAccess.OpenDocumentAsync(filePath).ConfigureAwait(false); + } + catch + { + document = null; + } + } + + if (document == null) + { + return null; + } + + var expectedSha = await CurrentShaAsync(document, filePath).ConfigureAwait(false); + if (receivedSha != null && receivedSha != expectedSha) + { + _logger?.Warn($"[pipeline] ignoring watch result path={posixPath} repo={repoRoot} stale sha"); + return null; + } + + return new ReviewSubmission + { + Document = document, + RelPath = posixPath, + Content = document.Content, + UpdateDiagnosticsPane = _fileAccess.IsVisible(filePath), + UpdateMonitor = true, + }; + } + + private async Task CurrentShaAsync(ReviewDocument document, string filePath) + { + if (document.IsDirty) + { + return GitBlobSha.FromUtf8(document.Content); + } + + var bytes = await _fileAccess.ReadFileBytesAsync(filePath).ConfigureAwait(false); + return bytes != null ? GitBlobSha.FromBytes(bytes) : GitBlobSha.FromUtf8(document.Content); + } + + 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 RpcPath.PathsEqual(pending.RepoRoot, repoRoot) + && RpcPath.ToPosixRelPath(pending.RelPath) == RpcPath.ToPosixRelPath(relPath) + && _latestByPath.TryGetValue(pending.PathKey, out var latest) + && ReferenceEquals(latest, pending) + && GitBlobSha.FromUtf8(pending.Document.Content) == pending.ContentHash + && (!_tombstones.TryGetValue(pending.PathKey, out var tombstone) || tombstone < pending.Generation); + } + + 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..c94a1dbe --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -0,0 +1,385 @@ +// 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.Interfaces; +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 +{ + [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 object _gate = new object(); + 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 _disposed; + + [ImportingConstructor] + public ReviewPipeline( + IIdeServerClient client, + [Import(AllowDefault = true)] IReviewPipelinePresentation presentation = null, + [Import(AllowDefault = true)] IReviewPipelineFileAccess fileAccess = null, + [Import(AllowDefault = true)] ILogger logger = null) + : this(client, presentation, fileAccess, null, logger) + { + } + + internal ReviewPipeline( + IIdeServerClient client, + IReviewPipelinePresentation presentation, + IReviewPipelineFileAccess fileAccess, + Func createId, + ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _presentation = presentation ?? NoOpPresentation.Instance; + _fileAccess = fileAccess ?? DiskFileAccess.Instance; + _createId = createId ?? (() => Guid.NewGuid().ToString("N")); + _logger = logger; + _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); + _presentation.Remove(document); + } + } + } + + public void Invalidate() + { + lock (_gate) + { + _dedupEpoch++; + } + } + + 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(); + } + } + + private static bool MatchesHash(string received, string expected) + { + return received == null || received == expected; + } + + private static string PathKey(string repoRoot, string relPath) + { + return RpcPath.NormalizeFsPath(repoRoot) + "\0" + RpcPath.ToPosixRelPath(relPath); + } + + 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 Task<(CliReviewModel Review, DeltaResponseModel Delta)[]> SubmitBatchInternalAsync(string repoRoot, IReadOnlyList submissions) + { + 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 (submission.Content == null) + { + diskFiles.Add(new ReviewFile { RelPath = RpcPath.ToPosixRelPath(submission.RelPath) }); + tasks[i] = Task.FromResult<(CliReviewModel, DeltaResponseModel)>((null, null)); + 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) + { + _client.ReviewFiles(repoRoot, files); + } + + return Task.WhenAll(tasks); + } + + 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) + { + MergePresentation(latest, normalized); + return latest; + } + + var reused = ReusedWatchReview(context, normalized, latest); + if (reused != null) + { + return reused; + } + + if (latest != null) + { + IgnorePending(latest); + } + + var pending = CreatePending(context, normalized); + _tombstones.Remove(pathKey); + _latestByPath[pathKey] = pending; + _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) + { + _presentation.PresentDelta(ToPresentedDelta(pending, pending.DeltaResult)); + } + } + + 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/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/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/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.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs new file mode 100644 index 00000000..1ce8f29a --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -0,0 +1,409 @@ +// 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.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 class WorkspaceWatchCoordinator : IWorkspaceWatchCoordinator, IDisposable + { + internal const int InventoryRefreshDelayMs = 250; + private readonly IIdeServerClient _client; + private readonly IReviewPipeline _pipeline; + private readonly ILogger _logger; + private readonly IWorkspaceWatchHost _host; + 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 _refreshTimers = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _knownRoots = new Dictionary(StringComparer.Ordinal); + private bool _disposed; + + [ImportingConstructor] + public WorkspaceWatchCoordinator( + IIdeServerClient client, + IReviewPipeline pipeline, + ILogger logger, + [Import(AllowDefault = true)] IWorkspaceWatchHost host = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + _logger = logger; + _host = host; + _client.WatchInventoryChanged += OnWatchInventoryChanged; + _client.ServerStarted += OnServerStarted; + _client.DeltaReceived += OnDeltaReceived; + } + + public Task SyncAsync(IReadOnlyList workspaceDirectories) + { + if (_disposed) + { + return Task.CompletedTask; + } + + var targets = ResolveTargets(workspaceDirectories ?? Array.Empty()); + lock (_gate) + { + _knownRoots.Clear(); + foreach (var target in targets) + { + _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); + } + } + } + + foreach (var target in targets) + { + EnsureWatch(target, ScopePaths(target, workspaceDirectories)); + } + + return Task.CompletedTask; + } + + public void StopAll() + { + lock (_gate) + { + foreach (var repoRoot in _watched.Keys.ToList()) + { + StopWatchingUnlocked(repoRoot); + } + } + } + + 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.DeltaReceived -= OnDeltaReceived; + StopAll(); + lock (_gate) + { + foreach (var timer in _refreshTimers.Values) + { + timer.Dispose(); + } + + _refreshTimers.Clear(); + } + } + + 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)) + { + 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)) + { + 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 void EnsureWatch(string repoRoot, IReadOnlyList relativePaths) + { + var key = RpcPath.NormalizeFsPath(repoRoot); + var scopeKey = relativePaths == null ? "*" : string.Join("|", relativePaths); + lock (_gate) + { + if (_watched.TryGetValue(key, out var existingScope) && existingScope == scopeKey) + { + Seed(repoRoot); + return; + } + + _client.WatchFiles(repoRoot, relativePaths); + _watched[key] = scopeKey; + } + + Seed(repoRoot); + } + + private void StopWatchingUnlocked(string normalizedRoot) + { + if (!_watched.TryGetValue(normalizedRoot, out var _)) + { + return; + } + + var repoRoot = _knownRoots.TryGetValue(normalizedRoot, out var known) ? known : normalizedRoot; + _client.StopWatchFiles(repoRoot); + _watched.Remove(normalizedRoot); + ApplyInventoryUnlocked(repoRoot, Array.Empty()); + } + + private void OnWatchInventoryChanged(object sender, WatchInventory inventory) + { + if (inventory == null) + { + return; + } + + lock (_gate) + { + ApplyInventoryUnlocked(inventory.RepoRoot, inventory.Files ?? Array.Empty()); + } + } + + private void ApplyInventoryUnlocked(string repoRoot, IReadOnlyList files) + { + var resolved = ResolveRepoRoot(repoRoot); + var key = RpcPath.NormalizeFsPath(resolved); + var relPaths = new HashSet(files.Select(RpcPath.ToPosixRelPath), StringComparer.Ordinal); + _inventories[key] = relPaths; + var keep = PathsToKeepUnlocked(); + _host?.PruneMonitor(_inventories.Keys.Select(ResolveRepoRoot).ToList(), keep); + } + + private void OnDeltaReceived(object sender, DeltaNotification notification) + { + if (notification?.Result == null) + { + return; + } + + lock (_gate) + { + var repoRoot = ResolveRepoRoot(notification.RepoRoot); + var key = RpcPath.NormalizeFsPath(repoRoot); + if (!_inventories.TryGetValue(key, out var inventory)) + { + return; + } + + var relPath = RpcPath.ToPosixRelPath(notification.Path); + if (inventory.Contains(relPath) || IsDirty(repoRoot, relPath)) + { + return; + } + + ScheduleInventoryRefresh(repoRoot, key); + } + } + + private void ScheduleInventoryRefresh(string repoRoot, string key) + { + if (_refreshTimers.TryGetValue(key, out var existing)) + { + existing.Change(InventoryRefreshDelayMs, Timeout.Infinite); + return; + } + + _refreshTimers[key] = new Timer( + _ => + { + lock (_gate) + { + if (_refreshTimers.TryGetValue(key, out var timer)) + { + timer.Dispose(); + _refreshTimers.Remove(key); + } + } + + _ = RefreshInventoryAsync(repoRoot); + }, + null, + InventoryRefreshDelayMs, + Timeout.Infinite); + } + + private async Task RefreshInventoryAsync(string 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 for {repoRoot}: {ex.Message}"); + } + } + + 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; + } + + return startEvent.Restart; + } + + private void Seed(string repoRoot) + { + 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; + } + + 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 true; + } + } + + return false; + } + + 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)); + } + } + + foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) + { + keep.Add(document.FilePath); + } + + return keep; + } + + private string ResolveRepoRoot(string reported) + { + return _knownRoots.TryGetValue(RpcPath.NormalizeFsPath(reported), out var known) + ? known + : Path.GetFullPath(reported ?? string.Empty); + } + } +} 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..2a4703d6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -2,14 +2,20 @@ 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(); @@ -50,6 +56,31 @@ public void OnDeltaCompleted(string filePath) } } + public void ApplyQueue(ReviewQueue queue) + { + lock (_lock) + { + _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); + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } + } + public void RequestViewUpdate() { ViewUpdateRequested?.Invoke(this, EventArgs.Empty); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs new file mode 100644 index 00000000..0d4fec05 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs @@ -0,0 +1,77 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Threading; +using Codescene.VSExtension.Core.Models.Cli.Rpc; + +namespace Codescene.VSExtension.Core.Application.Services +{ + public sealed class ReviewQueueProgress : IDisposable + { + public const int ThrottleMs = 250; + private readonly Action _onChange; + private readonly object _gate = new object(); + private ReviewQueue _latest = new ReviewQueue(); + private Timer _timer; + private bool _disposed; + + public ReviewQueueProgress(Action onChange) + { + _onChange = onChange ?? throw new ArgumentNullException(nameof(onChange)); + } + + public void Update(ReviewQueue snapshot) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _latest = snapshot ?? new ReviewQueue(); + if (_latest.Count == 0) + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + _onChange(_latest); + return; + } + + if (_timer == null) + { + _timer = new Timer(_ => Flush(), null, 0, Timeout.Infinite); + } + else + { + _timer.Change(ThrottleMs, Timeout.Infinite); + } + } + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _timer?.Dispose(); + _timer = null; + } + } + + private void Flush() + { + ReviewQueue snapshot; + lock (_gate) + { + if (_disposed) + { + return; + } + + snapshot = _latest; + } + + _onChange(snapshot); + } + } +} 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..11d37930 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,23 +20,20 @@ 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; [ImportingConstructor] public TelemetryManager( ILogger logger, - IProcessExecutor executor, + IIdeServerClient client, IDeviceIdStore deviceIdStore, - ICliCommandProvider cliCommandProvider, IExtensionMetadataProvider extensionMetadataProvider) { _logger = logger; - _executor = executor; + _client = client; _deviceIdStore = deviceIdStore; - _cliCommandProvider = cliCommandProvider; _extensionMetadataProvider = extensionMetadataProvider; } @@ -63,8 +61,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) { 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/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..0cd8fca3 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 @@ -19,6 +20,9 @@ + + analyzers + all runtime; build; native; contentfiles; analyzers @@ -34,14 +38,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..fc61b7d9 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 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/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/IIdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs new file mode 100644 index 00000000..440acf7f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs @@ -0,0 +1,62 @@ +// 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.Delta; +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 ReviewAsync(ReviewRequestModel request, CancellationToken cancellationToken = default); + + Task DeltaAsync(string oldScore, string newScore, 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..407a7850 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs @@ -0,0 +1,20 @@ +// Copyright (c) CodeScene. All rights reserved. + +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 Invalidate(); + } +} 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..90f85b4c 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,7 @@ public interface ISettingsProvider bool ShowDebugLogs { get; } string AuthToken { get; } + + int ServerWorkerThreads { get; } } } 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/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..d1ae31a2 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs @@ -0,0 +1,16 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Codescene.VSExtension.Core.Interfaces.Git +{ + public interface IWorkspaceWatchCoordinator + { + Task SyncAsync(IReadOnlyList workspaceDirectories); + + void StopAll(); + + 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..2f9d4d39 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs @@ -1,5 +1,7 @@ // Copyright (c) CodeScene. All rights reserved. +using Codescene.VSExtension.Core.Models.Cli.Rpc; + namespace Codescene.VSExtension.Core.Interfaces { public interface ICodeHealthMonitorNotifier @@ -9,5 +11,7 @@ public interface ICodeHealthMonitorNotifier void OnDeltaCompleted(string filePath); void RequestViewUpdate(); + + void ApplyQueue(ReviewQueue queue); } } 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..2b46404b 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 @@ -32,5 +32,11 @@ public class DeltaResponseModel /// [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/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/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..979f78a0 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs @@ -0,0 +1,19 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Delta; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class DeltaNotification + { + public string Id { get; set; } + + public string Path { get; set; } + + public string RepoRoot { get; set; } + + public DeltaResponseModel Result { get; set; } + + public ReviewQueue Queue { get; set; } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs new file mode 100644 index 00000000..6ee9457a --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs @@ -0,0 +1,15 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Newtonsoft.Json; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + internal class DeltaRequestParams + { + [JsonProperty("old-score", NullValueHandling = NullValueHandling.Ignore)] + public string OldScore { get; set; } + + [JsonProperty("new-score", NullValueHandling = NullValueHandling.Ignore)] + public string NewScore { get; set; } + } +} 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/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..24cfaa79 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs @@ -0,0 +1,17 @@ +// 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; } + + public ReviewQueue Queue { 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..e9478f8b --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs @@ -0,0 +1,19 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Models.Cli.Review; + +namespace Codescene.VSExtension.Core.Models.Cli.Rpc +{ + public class ReviewNotification + { + public string Id { get; set; } + + public string Path { get; set; } + + public string RepoRoot { get; set; } + + public CliReviewModel Result { get; set; } + + public ReviewQueue Queue { get; set; } + } +} 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..1dd75b96 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs @@ -0,0 +1,13 @@ +// 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 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/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/GitPathDiscovery.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs index 81004a68..0d29d1ba 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs @@ -8,6 +8,38 @@ namespace Codescene.VSExtension.Core.Util { public static class GitPathDiscovery { + 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) 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..b72551d4 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs @@ -0,0 +1,50 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.IO; +using Codescene.VSExtension.Core.Application.Util; + +namespace Codescene.VSExtension.Core.Util +{ + public static class RpcPath + { + 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 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) + { + return string.Equals(NormalizeFsPath(left), NormalizeFsPath(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); + } + } +} 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..6089e22f --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs @@ -0,0 +1,125 @@ +// 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.Models; +using Codescene.VSExtension.Core.Models.Cache.Review; +using Codescene.VSExtension.Core.Models.Cli.Rpc; +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; + + [ImportingConstructor] + public VisualStudioReviewPipelinePresentation( + IModelMapper mapper, + IErrorListWindowHandler errorListWindowHandler, + ICodeHealthMonitorNotifier notifier, + IAsyncTaskScheduler scheduler, + CodeSceneMarginSettingsManager marginSettings) + { + _mapper = mapper; + _errorListWindowHandler = errorListWindowHandler; + _notifier = notifier; + _scheduler = scheduler; + _marginSettings = marginSettings; + } + + 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; + } + + new DeltaCacheService().SetPresentationDelta(delta.Document.FilePath, delta.Result); + _notifier.RequestViewUpdate(); + } + + 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..20bf50b3 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs @@ -0,0 +1,95 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using Codescene.VSExtension.Core.Application.Cache.Review; +using Codescene.VSExtension.Core.Interfaces.Git; +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; + + [ImportingConstructor] + public VisualStudioWorkspaceWatchHost(IOpenDocumentContentProvider contentProvider) + { + _contentProvider = contentProvider; + } + + public IReadOnlyList GetDirtyDocuments() + { + var documents = new List(); + ThreadHelper.JoinableTaskFactory.Run(() => CollectDirtyDocumentsAsync(documents)); + return documents; + } + + public void PruneMonitor(IReadOnlyList repoRoots, ISet keepPaths) + { + new DeltaCacheService().RemoveEntriesNotIn(keepPaths); + } + + 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 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/Cli/CachingCodeReviewerProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs index 077150db..876210cc 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -21,10 +21,10 @@ namespace Codescene.VSExtension.VS2022.Application.Cli [PartCreationPolicy(CreationPolicy.Shared)] public class CachingCodeReviewerProvider : ICodeReviewer { - private readonly CachingCodeReviewer _inner; + private readonly ICodeReviewer _inner; private readonly CodeHealthMonitorNotifier _notifier; private readonly IAsyncTaskScheduler _scheduler; - private readonly IPreflightManager _preflightManager; + private readonly ReviewQueueProgress _queueProgress; [ImportingConstructor] public CachingCodeReviewerProvider( @@ -34,20 +34,20 @@ public CachingCodeReviewerProvider( ITelemetryManager telemetryManager, IGitService git, IAsyncTaskScheduler scheduler, - IPreflightManager preflightManager) + IPreflightManager preflightManager, + IReviewPipeline pipeline, + IIdeServerClient client, + CodeHealthMonitorNotifier notifier) { _scheduler = scheduler; - _preflightManager = preflightManager; - _notifier = new CodeHealthMonitorNotifier(); + _notifier = notifier; _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); + _queueProgress = new ReviewQueueProgress(queue => + { + _notifier.ApplyQueue(queue); + }); + client.QueueChanged += (_, queue) => _queueProgress.Update(queue); + _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, preflightManager, pipeline); } public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) 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/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/Codescene.VSExtension.VS2022.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj index bd18d96a..e4835158 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 @@ -59,8 +55,6 @@ $(DefineConstants);FEATURE_ACE - $(DefineConstants);FEATURE_PERIODIC_GIT_SCAN - $(DefineConstants);FEATURE_INITIAL_GIT_OBSERVER @@ -74,16 +68,15 @@ + + + - - - - @@ -225,9 +218,6 @@ 0.41.1 - - 3.1.3 - 17.0.31902.203 @@ -238,15 +228,9 @@ 1.0.1054.31 - - 2.6.81 - 3.0.14 - - 2.8.28 - all runtime; build; native; contentfiles; analyzers diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs index f75dad34..c7e0ec1c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs @@ -12,6 +12,8 @@ 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; @@ -64,6 +66,9 @@ public class EditorDocumentLifecycleHandler : IWpfTextViewCreationListener [Import] private readonly IAceRefactorSuggestedActionsNotifier _aceRefactorSuggestedActionsNotifier; + [Import] + private readonly IReviewPipeline _reviewPipeline; + public void TextViewCreated(IWpfTextView textView) { var buffer = textView.TextBuffer; @@ -109,8 +114,11 @@ public void TextViewCreated(IWpfTextView textView) textView.Closed += (_, _) => { _logger.Debug($"File closed: {filePath}..."); - - // TODO: Stop any pending analysis for optimization? + var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); + if (!string.IsNullOrEmpty(repoRoot) && _reviewPipeline != null) + { + _reviewPipeline.Remove(repoRoot, new ReviewDocument { FilePath = filePath, Content = string.Empty }); + } }; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs index 5628abfa..b9a2800d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs @@ -27,7 +27,7 @@ public class SolutionEventsHandler : IVsSolutionEvents, IDisposable private uint _cookie; private IVsSolution _solution; private BranchWatcherService _branchWatcher; - private IGitChangeObserver _gitChangeObserver; + private IWorkspaceWatchCoordinator _workspaceWatch; private IAsyncTaskScheduler _scheduler; private IErrorListWindowHandler _errorListWindowHandler; private Task _solutionInitializationTask; @@ -84,12 +84,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..."); @@ -123,9 +117,7 @@ public int OnBeforeCloseSolution(object pUnkReserved) _branchWatcher?.Dispose(); _branchWatcher = null; - _gitChangeObserver?.CancelAndReset(); - _gitChangeObserver?.Dispose(); - _gitChangeObserver = null; + _workspaceWatch?.StopAll(); return VSConstants.S_OK; } @@ -237,32 +229,19 @@ private async Task RunSolutionInitializationAsync(string solutionPath) _branchWatcher.StartWatching(solutionPath, (newBranch) => _scheduler.Schedule(ct2 => OnBranchChangedAsync(newBranch))); - await InitializeGitChangeObserverAsync(solutionPath); + await InitializeWorkspaceWatchAsync(solutionPath); } - private async Task InitializeGitChangeObserverAsync(string solutionPath) + private async Task InitializeWorkspaceWatchAsync(string solutionPath) { try { - _gitChangeObserver = await VS.GetMefServiceAsync(); - if (_gitChangeObserver == null) + _workspaceWatch = await VS.GetMefServiceAsync(); + if (_workspaceWatch == 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) - { - Log(logger => - { - logger.Warn("Failed to obtain required services for GitChangeObserver."); + logger.Warn("Failed to obtain IWorkspaceWatchCoordinator service."); return Task.CompletedTask; }); return; @@ -275,12 +254,11 @@ private async Task InitializeGitChangeObserverAsync(string solutionPath) workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); } - _gitChangeObserver.Initialize(solutionPath, savedFilesTracker, openFilesObserver, workspacePaths); - _gitChangeObserver.Start(); + await _workspaceWatch.SyncAsync(workspacePaths == null ? Array.Empty() : new List(workspacePaths)); Log(logger => { - logger.Info("GitChangeObserver initialized and started."); + logger.Info("Workspace watch initialized."); return Task.CompletedTask; }); } @@ -288,7 +266,7 @@ 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; }); } @@ -296,7 +274,7 @@ private async Task InitializeGitChangeObserverAsync(string solutionPath) private async Task RefreshWorkspacePathsAsync() { - if (_gitChangeObserver == null) + if (_workspaceWatch == null) { return; } @@ -315,10 +293,7 @@ private async Task RefreshWorkspacePathsAsync() } var workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); - if (workspacePaths != null && workspacePaths.Count > 0) - { - _gitChangeObserver.UpdateWorkspacePaths(workspacePaths); - } + await _workspaceWatch.SyncAsync(workspacePaths == null ? Array.Empty() : new List(workspacePaths)); } private async Task OnBranchChangedAsync(string newBranch) @@ -327,11 +302,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()); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs index 1f258722..2f49c0d1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs @@ -61,6 +61,11 @@ 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; + public override async Task LoadAsync() { await base.LoadAsync(); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs index f92c4f51..a2c2b528 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs @@ -12,5 +12,7 @@ public class GeneralSettingsProvider : ISettingsProvider public bool ShowDebugLogs => General.Instance.ShowDebugLogs; public string AuthToken => General.Instance.AuthToken; + + public int ServerWorkerThreads => General.Instance.ServerWorkerThreads; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 241f6864..815441a1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -42,6 +42,7 @@ public sealed class VS2022Package : ToolkitPackage private ILogger _logger; private SolutionEventsHandler _solutionEventsHandler; private IAsyncTaskScheduler _scheduler; + private IIdeServerClient _ideServerClient; public static VS2022Package Instance { get; private set; } @@ -71,6 +72,7 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Cli file await CheckCliFileAsync(); + await StartIdeServerAsync(cancellationToken); // Subscribe on active document change event await SubscribeOnActiveWindowChangeAsync(); @@ -111,6 +113,7 @@ protected override void Dispose(bool disposing) UnsubscribeFromGlobalExceptionHandlers(); _solutionEventsHandler?.Dispose(); (_scheduler as IDisposable)?.Dispose(); + _ideServerClient?.Dispose(); } base.Dispose(disposing); @@ -262,6 +265,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/Makefile b/Makefile index 85627bbc..5f68a8a2 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,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/SETUP.md b/SETUP.md index 5402c89e..28a8e8fa 100644 --- a/SETUP.md +++ b/SETUP.md @@ -50,11 +50,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. From c562ef63fab4198da2db65df2fd7301b6e1ec12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 14:08:01 +0200 Subject: [PATCH 02/38] fix buildtools version --- .../Codescene.VSExtension.VS2022.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e4835158..73e20355 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -42,6 +42,7 @@ prompt 4 bin\Debug\Codescene.VSExtension.VS2022.xml + true pdbonly @@ -221,7 +222,7 @@ 17.0.31902.203 - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 58c3abe3d43cd3dafb53a8a4e40f05c9217f4b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 14:12:52 +0200 Subject: [PATCH 03/38] fix: show CLI logs and clear monitor on solution switch Route cs-ide stderr into the CodeScene output pane, and drop previous-solution reviews when the solution changes. --- .../GitChangeDetectorMergeCommitTests.cs | 106 ------------------ .../IdeServerClientTests.cs | 23 +++- .../ReviewPipelineTests.cs | 22 ++++ .../ReviewQueueProgressTests.cs | 19 ++++ .../WorkspaceWatchCoordinatorTests.cs | 89 +++++++++++---- .../Application/Cli/IdeServerClient.cs | 5 +- .../Cli/ReviewPipeline.Notifications.cs | 80 ++++++++++--- .../Application/Cli/ReviewPipeline.cs | 28 +++++ .../Git/WorkspaceWatchCoordinator.cs | 26 +++++ .../Services/CodeHealthMonitorNotifier.cs | 10 ++ .../Interfaces/Cli/IReviewPipeline.cs | 5 + .../Interfaces/ICodeHealthMonitorNotifier.cs | 2 + .../Handlers/SolutionEventsHandler.cs | 10 ++ 13 files changed, 279 insertions(+), 146 deletions(-) delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitChangeDetectorMergeCommitTests.cs 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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index d2ea6b47..edc7ad20 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -110,6 +110,17 @@ public async Task RepeatedReviews_UseSingleProcess() Assert.AreEqual(1, harness.Factory.StartCount); } + [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() { @@ -210,10 +221,11 @@ private string Sha256(string path) internal sealed class IdeServerTestHarness : IDisposable { - private IdeServerTestHarness(IdeServerClient client, FakeProcessFactory factory, JsonRpc serverRpc, Stream serverStream) + private IdeServerTestHarness(IdeServerClient client, FakeProcessFactory factory, FakeIdeServerProcess process, JsonRpc serverRpc, Stream serverStream) { Client = client; Factory = factory; + Process = process; ServerRpc = serverRpc; ServerStream = serverStream; } @@ -222,6 +234,8 @@ private IdeServerTestHarness(IdeServerClient client, FakeProcessFactory factory, public FakeProcessFactory Factory { get; } + public FakeIdeServerProcess Process { get; } + public JsonRpc ServerRpc { get; } public Stream ServerStream { get; } @@ -238,7 +252,7 @@ public static async Task StartAsync( serverRpc.AddLocalRpcTarget(new FixtureServer(serverRpc)); serverRpc.StartListening(); var client = new IdeServerClient(settings.Object, logger.Object, factory, userSettings.Object, TimeSpan.FromSeconds(5)); - var harness = new IdeServerTestHarness(client, factory, serverRpc, serverStream); + var harness = new IdeServerTestHarness(client, factory, process, serverRpc, serverStream); _ = Task.Run(async () => { await Task.Yield(); @@ -388,6 +402,11 @@ public void BeginErrorReadLine() { } + public void RaiseError(string data) + { + ErrorDataReceived?.Invoke(this, new IdeServerErrorDataEventArgs(data)); + } + public void Kill() { HasExited = true; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs index 6aed4ee4..ec2cc945 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -332,6 +332,28 @@ public async Task WatchReview_StillSubmitsAfterInvalidate() 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); + } + private void CompleteReview(string id, string repoRoot = RepoRoot, string path = "src/file.ts") { RaiseReview(id, repoRoot, path); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs index 7fa097e0..9736d7e3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs @@ -40,5 +40,24 @@ public void ApplyQueue_MapsRunningAndQueuedJobs() Assert.AreEqual(WebComponentConstants.StateTypes.QUEUED, jobs[1].State); DeltaJobTracker.Clear(); } + + [TestMethod] + public void Clear_RemovesJobsAndRequestsViewUpdate() + { + DeltaJobTracker.Clear(); + var notifier = new CodeHealthMonitorNotifier(); + 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); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index 591c776e..6863b1c7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -38,30 +38,43 @@ public async Task Sync_SendsWatchFilesForResolvedRepo() } [TestMethod] - public void ApplyInventory_PrunesMonitorAndPreservesDirtyBuffers() + public async Task ApplyInventory_PrunesMonitorAndPreservesDirtyBuffers() { - var client = new Mock(); - var pipeline = new Mock(); - var host = new Mock(); - ISet keep = null; - host.Setup(x => x.GetDirtyDocuments()).Returns(new[] + var repo = CreateGitRepo(); + try { - new ReviewDocument { FilePath = @"C:\repo\dirty.cs", Content = "x", IsDirty = true }, - }); - host.Setup(x => x.PruneMonitor(It.IsAny>(), It.IsAny>())) - .Callback, ISet>((_, paths) => keep = paths); + 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" } }); - using (var coordinator = new WorkspaceWatchCoordinator(client.Object, pipeline.Object, null, host.Object)) + 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 { - client.Raise( - x => x.WatchInventoryChanged += null, - client.Object, - new WatchInventory { RepoRoot = @"C:\repo", Files = new[] { "tracked.cs" } }); + if (Directory.Exists(repo)) + { + Directory.Delete(repo, true); + } } - - 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))); } [TestMethod] @@ -113,6 +126,8 @@ public async Task StopAll_SendsStopWatchFiles() } 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 { @@ -158,6 +173,42 @@ public async Task ServerRestart_RestoresWatchesAndDirtyBuffers() } } + [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); + } + } + } + private static string CreateGitRepo() { var path = Path.Combine(Path.GetTempPath(), "codescene-watch-" + Guid.NewGuid().ToString("N")); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs index 4a5bce9d..4c8da196 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -179,6 +179,7 @@ public void ReviewFiles(string repoRoot, IReadOnlyList files) Content = file.Content, }).ToList(), }; + _logger.Info($"[cs-ide] sending reviewFiles count={payload.Files.Count}"); _ = SendNotificationAsync("cs-ide/reviewFiles", payload); } @@ -261,7 +262,7 @@ private async Task StartCoreAsync() var trimmed = (e.Data ?? string.Empty).Trim(); if (trimmed.Length > 0) { - _logger.Debug("[cs-ide] " + trimmed); + _logger.Info("[cs-ide] " + trimmed); } }; process.BeginErrorReadLine(); @@ -422,6 +423,7 @@ private void HandleReview(JObject notification) var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); var queue = ParseQueue(normalized, identity.Item3); EmitQueue(queue); + _logger.Info($"[cs-ide] received fileReview id={identity.Item1 ?? "(none)"} path={identity.Item2}"); ReviewReceived?.Invoke(this, new ReviewNotification { Id = identity.Item1, @@ -444,6 +446,7 @@ private void HandleDelta(JObject notification) var queue = ParseQueue(normalized, identity.Item3); EmitQueue(queue); var resultToken = normalized["result"]; + _logger.Info($"[cs-ide] received deltaReview id={identity.Item1 ?? "(none)"} path={identity.Item2}"); DeltaReceived?.Invoke(this, new DeltaNotification { Id = identity.Item1, 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 index 2f599dc4..fd7f5995 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -128,6 +128,17 @@ private void OnServerError(object sender, Exception 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) { @@ -136,37 +147,60 @@ private async Task PresentWatchReviewAsync(ReviewNotification notification) 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, + }); } - - _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) { + 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; } - _presentation.PresentDelta(new PresentedDelta + lock (_gate) { - Document = presented.Document, - RelPath = presented.RelPath, - Content = presented.Content, - UpdateDiagnosticsPane = presented.UpdateDiagnosticsPane, - UpdateMonitor = presented.UpdateMonitor, - Result = notification.Result, - }); + 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) @@ -248,6 +282,16 @@ private async Task CurrentShaAsync(ReviewDocument document, string fileP return bytes != null ? GitBlobSha.FromBytes(bytes) : GitBlobSha.FromUtf8(document.Content); } + private bool AcceptsWatchRepoUnlocked(string repoRoot) + { + if (_activeRepos == null) + { + return true; + } + + return _activeRepos.Contains(RpcPath.NormalizeFsPath(repoRoot)); + } + private void FailAll(Exception error) { List pendingReviews; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs index c94a1dbe..cf7aff9e 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -32,6 +32,7 @@ public sealed partial class ReviewPipeline : IReviewPipeline, IDisposable private readonly Dictionary _watchReviews = new Dictionary(StringComparer.Ordinal); private readonly List _watchReviewOrder = new List(); private int _dedupEpoch; + private HashSet _activeRepos; private bool _disposed; [ImportingConstructor] @@ -112,6 +113,33 @@ public void Invalidate() } } + public void Reset() + { + lock (_gate) + { + foreach (var pending in _pendingById.Values.ToList()) + { + CompletePending(pending); + } + + _dedupEpoch++; + _latestByPath.Clear(); + _tombstones.Clear(); + _watchReviews.Clear(); + _watchReviewOrder.Clear(); + } + } + + public void SetActiveRepos(IReadOnlyCollection repoRoots) + { + lock (_gate) + { + _activeRepos = repoRoots == null + ? null + : new HashSet(repoRoots.Select(RpcPath.NormalizeFsPath), StringComparer.Ordinal); + } + } + public void Dispose() { if (_disposed) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs index 1ce8f29a..f940cfac 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -90,7 +90,20 @@ public void StopAll() { StopWatchingUnlocked(repoRoot); } + + _inventories.Clear(); + _knownRoots.Clear(); + foreach (var timer in _refreshTimers.Values) + { + timer.Dispose(); + } + + _refreshTimers.Clear(); + _pipeline.SetActiveRepos(Array.Empty()); } + + _pipeline.Reset(); + _host?.PruneMonitor(Array.Empty(), new HashSet(StringComparer.OrdinalIgnoreCase)); } public IReadOnlyCollection GetInventoryFiles(string repoRoot) @@ -195,6 +208,7 @@ private void EnsureWatch(string repoRoot, IReadOnlyList relativePaths) _client.WatchFiles(repoRoot, relativePaths); _watched[key] = scopeKey; + PublishActiveReposUnlocked(); } Seed(repoRoot); @@ -210,6 +224,7 @@ private void StopWatchingUnlocked(string normalizedRoot) var repoRoot = _knownRoots.TryGetValue(normalizedRoot, out var known) ? known : normalizedRoot; _client.StopWatchFiles(repoRoot); _watched.Remove(normalizedRoot); + PublishActiveReposUnlocked(); ApplyInventoryUnlocked(repoRoot, Array.Empty()); } @@ -222,6 +237,12 @@ private void OnWatchInventoryChanged(object sender, WatchInventory inventory) lock (_gate) { + var key = RpcPath.NormalizeFsPath(ResolveRepoRoot(inventory.RepoRoot)); + if (!_watched.ContainsKey(key)) + { + return; + } + ApplyInventoryUnlocked(inventory.RepoRoot, inventory.Files ?? Array.Empty()); } } @@ -399,6 +420,11 @@ private ISet PathsToKeepUnlocked() return keep; } + private void PublishActiveReposUnlocked() + { + _pipeline.SetActiveRepos(_watched.Keys.Select(ResolveRepoRoot).ToList()); + } + private string ResolveRepoRoot(string reported) { return _knownRoots.TryGetValue(RpcPath.NormalizeFsPath(reported), out var known) 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 2a4703d6..847207f1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -81,6 +81,16 @@ public void ApplyQueue(ReviewQueue queue) } } + public void Clear() + { + lock (_lock) + { + _activeJobs.Clear(); + DeltaJobTracker.Clear(); + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } + } + public void RequestViewUpdate() { ViewUpdateRequested?.Invoke(this, EventArgs.Empty); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs index 407a7850..010a2242 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs @@ -1,5 +1,6 @@ // Copyright (c) CodeScene. All rights reserved. +using System.Collections.Generic; using System.Threading.Tasks; using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Review; @@ -16,5 +17,9 @@ public interface IReviewPipeline void Remove(string repoRoot, params ReviewDocument[] documents); void Invalidate(); + + void Reset(); + + void SetActiveRepos(IReadOnlyCollection repoRoots); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs index 2f9d4d39..658d6214 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs @@ -13,5 +13,7 @@ public interface ICodeHealthMonitorNotifier void RequestViewUpdate(); void ApplyQueue(ReviewQueue queue); + + void Clear(); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs index b9a2800d..7630c38d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs @@ -5,6 +5,7 @@ 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; @@ -28,6 +29,8 @@ public class SolutionEventsHandler : IVsSolutionEvents, IDisposable private IVsSolution _solution; private BranchWatcherService _branchWatcher; private IWorkspaceWatchCoordinator _workspaceWatch; + private IReviewPipeline _reviewPipeline; + private ICodeHealthMonitorNotifier _codeHealthMonitorNotifier; private IAsyncTaskScheduler _scheduler; private IErrorListWindowHandler _errorListWindowHandler; private Task _solutionInitializationTask; @@ -39,6 +42,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(); @@ -73,6 +78,7 @@ public int OnAfterCloseSolution(object pUnkReserved) new ReviewCacheService().Clear(); new AceRefactorableFunctionsCacheService().Clear(); CacheGeneration.Increment(); + _codeHealthMonitorNotifier?.Clear(); _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); _scheduler.Schedule(ct => AceToolWindow.CloseAsync()); @@ -118,6 +124,9 @@ public int OnBeforeCloseSolution(object pUnkReserved) _branchWatcher?.Dispose(); _branchWatcher = null; _workspaceWatch?.StopAll(); + _reviewPipeline?.Reset(); + _reviewPipeline?.SetActiveRepos(Array.Empty()); + _codeHealthMonitorNotifier?.Clear(); return VSConstants.S_OK; } @@ -221,6 +230,7 @@ private async Task RunSolutionInitializationAsync(string solutionPath) new BaselineReviewCacheService().Clear(); new ReviewCacheService().Clear(); new AceRefactorableFunctionsCacheService().Clear(); + _codeHealthMonitorNotifier?.Clear(); await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); _errorListWindowHandler?.ClearAll(); From 8b3c24a313dea12808fe62405fc045965e9d13ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 15:02:19 +0200 Subject: [PATCH 04/38] chore: allow oversized persistent CLI migration skip-pr-size-check From dff4238001e0777ac86d648fa82f9113407d7edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 15:58:33 +0200 Subject: [PATCH 05/38] fix: pass repository root to persistent CLI reviews Preserve cross-drive review behavior and cover persistent server notification, queue, cache, and workspace edge cases. --- .../CliExecutorTests.cs | 30 ++ .../CodeReviewerTests.cs | 75 +++++ .../DeltaCacheServiceTests.cs | 18 + .../DeltaJobTrackerTests.cs | 23 ++ .../GitPathHelperTests.cs | 14 + .../IdeServerClientTests.cs | 218 +++++++++++- .../PathUtilitiesTests.cs | 9 + .../ReviewPipelineTests.cs | 209 ++++++++++++ .../ReviewQueueProgressTests.cs | 35 ++ .../WorkspaceWatchCoordinatorTests.cs | 317 ++++++++++++++++++ .../Application/Cli/CliExecutor.cs | 1 + .../Models/Cli/Review/ReviewRequestModel.cs | 3 + 12 files changed, 941 insertions(+), 11 deletions(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs index 57918efa..462ab499 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs @@ -11,6 +11,7 @@ using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Models.Cli.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; +using LibGit2Sharp; using Moq; namespace Codescene.VSExtension.Core.Tests @@ -62,6 +63,35 @@ public async Task ReviewContentAsync_WithValidResponse_ReturnsCliReviewModel() Times.Once); } + [TestMethod] + public async Task ReviewContentAsync_FileInGitRepository_IncludesRepositoryRoot() + { + var repositoryRoot = Path.Combine(Path.GetTempPath(), "cli-review-repo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repositoryRoot); + Repository.Init(repositoryRoot); + var filePath = Path.Combine(repositoryRoot, "test.cs"); + File.WriteAllText(filePath, TestFileContent); + ReviewRequestModel request = null; + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => request = value) + .ReturnsAsync(new CliReviewModel()); + + try + { + await _cliExecutor.ReviewContentAsync(filePath, TestFileContent); + + var repoPathProperty = typeof(ReviewRequestModel).GetProperty("RepoPath"); + Assert.IsNotNull(repoPathProperty); + Assert.AreEqual( + Path.GetFullPath(repositoryRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + repoPathProperty.GetValue(request)); + } + finally + { + Directory.Delete(repositoryRoot, recursive: true); + } + } + [TestMethod] public async Task ReviewContentAsync_WhenClientThrowsDevtoolsException_ThrowsException() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs index 3e958b96..20684f0d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs @@ -10,6 +10,7 @@ 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 @@ -580,5 +581,79 @@ public async Task DeltaAsync_WithPreflightManager_NonMatchingRefactorables_DoesN Assert.IsNotNull(result); Assert.IsNull(result.FunctionLevelFindings[0].RefactorableFn); } + + [TestMethod] + public async Task ReviewWithDeltaAsync_WithPipelineOutsideRepository_ReturnsNull() + { + var pipeline = new Mock(); + var reviewer = new CodeReviewer( + _mockLogger.Object, + _mockMapper.Object, + _mockExecutor.Object, + _mockTelemetryManager.Object, + _mockGitService.Object, + pipeline: pipeline.Object); + + var result = await reviewer.ReviewWithDeltaAsync( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "test.cs"), + "content"); + + Assert.IsNull(result.review); + Assert.IsNull(result.delta); + pipeline.Verify( + x => x.SubmitAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() + { + 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, + _mockTelemetryManager.Object, + _mockGitService.Object, + pipeline: pipeline.Object); + + try + { + var result = await reviewer.ReviewWithDeltaAsync(path, "content"); + + 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 == "content" && + submission.UpdateDiagnosticsPane && + submission.UpdateMonitor)), + Times.Once); + } + finally + { + Directory.Delete(repoRoot, true); + } + } } } 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..9a9967e5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs @@ -143,6 +143,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/GitPathHelperTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs index c548acde..15da1aa2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs @@ -164,6 +164,20 @@ public void ConvertToAbsolutePath_InvalidPath_FallsBackToPathCombine() Assert.AreEqual(Path.Combine(_tempDir, invalidRelative), result); } + [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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index edc7ad20..470683a2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -6,6 +6,7 @@ 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; @@ -132,6 +133,122 @@ public async Task DeviceIdAsync_ReadsCamelCaseOrKebabCase() Assert.AreEqual("device-42", deviceId); } + [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 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_RaisesDeltaAndQueueNotifications() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + var deltaReceived = new TaskCompletionSource(); + var queueReceived = new TaskCompletionSource(); + harness.Client.DeltaReceived += (_, delta) => deltaReceived.TrySetResult(delta); + harness.Client.QueueChanged += (_, queue) => queueReceived.TrySetResult(queue); + + 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", + }, + queue = new { count = 1, files = new[] { "src/b.cs" } }, + }); + + var delta = await WaitForAsync(deltaReceived.Task); + var queue = await WaitForAsync(queueReceived.Task); + 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((1, Path.Combine("C:/repo", "src", "b.cs")), (queue.Count, queue.Files[0])); + } + + [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() { @@ -146,10 +263,9 @@ public async Task WatchFiles_RaisesInventoryAndReviewNotifications() var inventory = await WaitForAsync(inventoryReceived.Task); var review = await WaitForAsync(reviewReceived.Task); - Assert.AreEqual("C:/repo", inventory.RepoRoot); - Assert.AreEqual("watched.ts", inventory.Files[0]); - Assert.IsNull(review.Id); - Assert.AreEqual("watched.ts", review.Path); + Assert.AreEqual( + ("C:/repo", "watched.ts", null, "watched.ts"), + (inventory.RepoRoot, inventory.Files[0], review.Id, review.Path)); } [TestMethod] @@ -167,10 +283,9 @@ public async Task ReviewFiles_EchoesIdAndQueue() var review = await WaitForAsync(reviewReceived.Task); var queue = await WaitForAsync(queueReceived.Task); - Assert.AreEqual("req-1", review.Id); - Assert.AreEqual("src/a.ts", review.Path); - Assert.AreEqual(GitBlobSha.FromUtf8("hello"), review.Result.GitBlobSha); - Assert.AreEqual(2, queue.Count); + Assert.AreEqual( + ("req-1", "src/a.ts", GitBlobSha.FromUtf8("hello"), 2), + (review.Id, review.Path, review.Result.GitBlobSha, queue.Count)); } [TestMethod] @@ -189,6 +304,33 @@ public async Task ReviewFailed_TreatsNullIdAsMissing() 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( @@ -221,13 +363,20 @@ private string Sha256(string path) internal sealed class IdeServerTestHarness : IDisposable { - private IdeServerTestHarness(IdeServerClient client, FakeProcessFactory factory, FakeIdeServerProcess process, JsonRpc serverRpc, Stream serverStream) + 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; } @@ -240,6 +389,8 @@ private IdeServerTestHarness(IdeServerClient client, FakeProcessFactory factory, public Stream ServerStream { get; } + public FixtureServer Server { get; } + public static async Task StartAsync( Mock settings, Mock logger, @@ -249,10 +400,11 @@ public static async Task StartAsync( var process = new FakeIdeServerProcess(clientStream, clientStream); var factory = new FakeProcessFactory(process); var serverRpc = new JsonRpc(new HeaderDelimitedMessageHandler(serverStream, serverStream)); - serverRpc.AddLocalRpcTarget(new FixtureServer(serverRpc)); + 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); + var harness = new IdeServerTestHarness(client, factory, process, serverRpc, serverStream, server); _ = Task.Run(async () => { await Task.Yield(); @@ -278,6 +430,8 @@ public FixtureServer(JsonRpc rpc) _rpc = rpc; } + public TaskCompletionSource StopWatchReceived { get; } = new TaskCompletionSource(); + [JsonRpcMethod("cs-ide/review", UseSingleObjectParameterDeserialization = true)] public JObject Review(JObject request) { @@ -290,6 +444,48 @@ 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 JObject Refactor(JObject request) + { + 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) + { + 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) { 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/ReviewPipelineTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs index ec2cc945..de4203e6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -354,6 +354,215 @@ public async Task Reset_DropsLaterWatchDelta() 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); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs index 9736d7e3..b5be5288 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs @@ -23,6 +23,41 @@ public void Update_ZeroCount_FlushesImmediately() Assert.IsTrue(snapshots.Exists(queue => queue.Count == 0)); } + [TestMethod] + public void Update_SubsequentPositiveCount_FlushesLatestSnapshot() + { + var flushed = new ManualResetEventSlim(); + var snapshots = new List(); + using (var progress = new ReviewQueueProgress(snapshot => + { + snapshots.Add(snapshot); + flushed.Set(); + })) + { + progress.Update(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); + Assert.IsTrue(flushed.Wait(TimeSpan.FromSeconds(5))); + flushed.Reset(); + + progress.Update(new ReviewQueue { Count = 2, Files = new[] { "a.cs", "b.cs" } }); + + Assert.IsTrue(flushed.Wait(TimeSpan.FromSeconds(5))); + } + + Assert.AreEqual(2, snapshots[snapshots.Count - 1].Count); + } + + [TestMethod] + public void Update_AfterDispose_IsIgnored() + { + var changes = 0; + var progress = new ReviewQueueProgress(_ => changes++); + progress.Dispose(); + + progress.Update(new ReviewQueue { Count = 1 }); + + Assert.AreEqual(0, changes); + } + [TestMethod] public void ApplyQueue_MapsRunningAndQueuedJobs() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index 6863b1c7..d8e61b91 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -3,6 +3,7 @@ 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; @@ -209,6 +210,322 @@ public async Task StopAll_IgnoresLaterInventoryForClosedRepo() } } + [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" } }); + + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification + { + RepoRoot = repo, + Path = "new.cs", + Result = new DeltaResponseModel(), + }); + + 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" } }); + + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification { RepoRoot = repo, Path = "known.cs", Result = new DeltaResponseModel() }); + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification { RepoRoot = repo, Path = "dirty.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 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 delta = new DeltaNotification + { + RepoRoot = repo, + Path = "new.cs", + Result = new DeltaResponseModel(), + }; + + client.Raise(x => x.DeltaReceived += null, client.Object, delta); + client.Raise(x => x.DeltaReceived += null, client.Object, delta); + + 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() }); + client.Raise( + x => x.DeltaReceived += null, + client.Object, + new DeltaNotification { RepoRoot = repo, Path = "new.cs", Result = new DeltaResponseModel() }); + + coordinator.StopAll(); + await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 100); + + client.Verify( + x => x.GetWatchInventoryAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + 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")); 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 e2ad4085..556e847b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs @@ -99,6 +99,7 @@ public async Task ReviewContentAsync(string filePath, string con FilePath = filePath, FileContent = content, CachePath = _cacheStorage.GetSolutionReviewCacheLocation(), + RepoPath = GitPathDiscovery.TryGetWorkingDirectory(filePath), }; var (result, elapsedMs) = await ExecuteOnChannelAsync( 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 index 9cdcb942..a60f2b34 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs @@ -14,5 +14,8 @@ public class ReviewRequestModel [JsonProperty("cache-path")] public string CachePath { get; set; } + + [JsonProperty("repo-path")] + public string RepoPath { get; set; } } } From abde6d44ac480f55a4373369ac191f2f3c8b86da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 16 Sep 2026 16:14:49 +0200 Subject: [PATCH 06/38] fix: preserve non-repository review working paths Use the legacy workspace and containing-directory fallbacks so persistent CLI reviews work across Windows drives. --- .../CliExecutorTests.cs | 24 ++++++++++++++ .../Application/Cli/CliExecutor.cs | 32 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs index 462ab499..7ec33ec2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs @@ -92,6 +92,30 @@ public async Task ReviewContentAsync_FileInGitRepository_IncludesRepositoryRoot( } } + [TestMethod] + public async Task ReviewContentAsync_FileOutsideGitRepository_IncludesContainingDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "cli-review-file-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var filePath = Path.Combine(directory, "test.cs"); + File.WriteAllText(filePath, TestFileContent); + ReviewRequestModel request = null; + _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => request = value) + .ReturnsAsync(new CliReviewModel()); + + try + { + await _cliExecutor.ReviewContentAsync(filePath, TestFileContent); + + Assert.AreEqual(directory, request.RepoPath); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [TestMethod] public async Task ReviewContentAsync_WhenClientThrowsDevtoolsException_ThrowsException() { 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 556e847b..bbfb79c8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.cs @@ -99,7 +99,7 @@ public async Task ReviewContentAsync(string filePath, string con FilePath = filePath, FileContent = content, CachePath = _cacheStorage.GetSolutionReviewCacheLocation(), - RepoPath = GitPathDiscovery.TryGetWorkingDirectory(filePath), + RepoPath = GetReviewRepoPath(filePath), }; var (result, elapsedMs) = await ExecuteOnChannelAsync( @@ -358,6 +358,36 @@ private void SendPerformanceTelemetry(PerformanceTelemetryData telemetryData) }); } + private string GetReviewRepoPath(string filePath) + { + var gitRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); + if (!string.IsNullOrEmpty(gitRoot)) + { + return gitRoot; + } + + var workspace = _cacheStorage.GetWorkspaceDirectory(); + if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) + { + return PathNormalization.NormalizeWorkingDirectory(workspace); + } + + if (string.IsNullOrWhiteSpace(filePath)) + { + return null; + } + + try + { + return Path.GetDirectoryName(Path.GetFullPath(filePath)); + } + catch (Exception ex) + { + _logger.Debug($"Could not resolve review repository path: {ex.Message}"); + return null; + } + } + private async Task> ExecuteFnsToRefactorAsync( bool isValid, string skipMessage, From 68a81850924e19b22ab37a3d5ebea222141f63a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 2 Oct 2026 10:09:13 +0200 Subject: [PATCH 07/38] feat: sync persistent CLI behavior with the VS Code extension Match the VS Code client for diagnostic logging, discarded-edit restore, watch-inventory gating, and automatic analysis controls. Keep ACE diff files on the open document's line endings so Visual Studio does not warn about mixed endings. --- .../BenchmarkSupport.cs | 2 + .../TestSettingsProvider.cs | 2 + .../GitServiceSubcutaneousTests.cs | 3 +- .../TestHarnessServices.cs | 2 + .../CliSettingsProviderTests.cs | 4 +- .../CodeReviewerTests.cs | 4 +- .../PersistentCliSyncTests.cs | 60 ++++ .../ReviewPipelineOwnershipTests.cs | 150 ++++++++ .../TextUtilsTests.cs | 19 ++ .../WorkspaceWatchCoordinatorTests.cs | 99 ++++-- .../Application/Cli/CliSettingsProvider.cs | 4 +- .../Application/Cli/CodeReviewer.cs | 35 +- .../Cli/IdeServerClient.Notifications.cs | 205 +++++++++++ .../Application/Cli/IdeServerClient.cs | 249 ++++---------- .../Application/Cli/IdeServerLog.cs | 319 ++++++++++++++++++ .../Cli/ReviewPipeline.Notifications.cs | 118 ++++++- .../Application/Cli/ReviewPipeline.cs | 117 ++++++- .../WorkspaceWatchCoordinator.Admission.cs | 226 +++++++++++++ .../Git/WorkspaceWatchCoordinator.cs | 164 ++++----- .../Mappers/CodeHealthMonitorMapper.cs | 12 +- .../Services/AnalysisBatchTracker.cs | 37 ++ .../Services/CodeHealthMonitorNotifier.cs | 67 ++++ .../Application/Util/DebounceService.cs | 15 + .../Consts/WebComponentConstants.cs | 2 + .../Interfaces/Cli/IReviewPipeline.cs | 7 + .../Interfaces/Extension/ISettingsProvider.cs | 2 + .../Git/IWorkspaceWatchCoordinator.cs | 2 + .../Interfaces/ICodeHealthMonitorNotifier.cs | 8 + .../Interfaces/Util/IDebounceService.cs | 2 + .../Models/Cli/Delta/DeltaResponseModel.cs | 2 +- .../Data/CodeHealthMonitorComponentData.cs | 6 + .../Util/DeltaScoreText.cs | 24 ++ .../Util/LogFields.cs | 89 +++++ .../Util/TextUtils.cs | 89 ++++- .../Cli/CachingCodeReviewerProvider.cs | 7 +- .../Commands/AutomaticAnalysisCommands.cs | 34 ++ .../EditorMargin/CodeSceneMargin.cs | 4 +- .../EditorDocumentLifecycleHandler.cs | 101 +++++- .../Options/General.cs | 33 ++ .../Options/GeneralSettingsProvider.cs | 2 + .../WebComponent/Handlers/ShowDiffHandler.cs | 8 +- .../Handlers/WebComponentMessageHandler.cs | 26 +- .../VS2022Package.cs | 2 +- .../VSCommandTable.cs | 2 + .../VSCommandTable.vsct | 14 + 45 files changed, 2031 insertions(+), 348 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/DeltaScoreText.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs index acce74ba..67bcc302 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs @@ -175,6 +175,8 @@ internal sealed class FixedSettingsProvider : ISettingsProvider public string AuthToken => string.Empty; public int ServerWorkerThreads => 0; + + public bool EnableAutomaticAnalysis => true; } internal sealed class FixedGitService : IGitService 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 6cf76cec..04b94a83 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs @@ -17,5 +17,7 @@ internal class TestSettingsProvider : ISettingsProvider public string AuthToken => Mock.Object.AuthToken; public int ServerWorkerThreads => Mock.Object.ServerWorkerThreads; + + public bool EnableAutomaticAnalysis => true; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs index 2bd8a5af..6962115f 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs @@ -147,7 +147,8 @@ public async Task ReviewWithDeltaAsync_OnFeatureBranchBasedOnDevelop_UsesDevelop var (featureReview, delta) = await CodeReviewer.ReviewWithDeltaAsync(absolutePath, FeatureContent); Assert.IsNotNull(delta); - Assert.AreEqual(RoundScore(developReview.Score), RoundScore(delta.OldScore)); + Assert.IsNotNull(delta.OldScore); + Assert.AreEqual(RoundScore(developReview.Score), RoundScore(delta.OldScore.GetValueOrDefault())); Assert.AreEqual(RoundScore(featureReview.Score), RoundScore(delta.NewScore)); Assert.IsLessThan(RoundScore(developReview.Score), RoundScore(featureReview.Score)); } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs index 9a490bfd..43a434b7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs @@ -90,4 +90,6 @@ public sealed class TestSettingsProvider : ISettingsProvider public string AuthToken => string.Empty; public int ServerWorkerThreads => 0; + + public bool EnableAutomaticAnalysis => true; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs index 3fb69086..cada0dd8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs @@ -8,8 +8,8 @@ namespace Codescene.VSExtension.Core.Tests [TestClass] public class CliSettingsProviderTests { - private readonly string expectedVersion = "8a7257420cc2dec1cf6ff7866db4da8c58f67602"; - private readonly string expectedCliBinarySha256 = "41b68ec72e3f50ba58b5292608d21b9d51670a5e85105868a6fcaa75a3883c02"; + private readonly string expectedVersion = "fbf6c11101be5b35288d64b9aee9a3c97573667a"; + private readonly string expectedCliBinarySha256 = "eff7759f0c23b54938df6bbf4ce0a11bd1690097ad9b167fe8df602dcb3d820e"; [TestMethod] public void RequiredDevToolVersion_ShouldReturnExpectedValue() diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs index 20684f0d..b5fd431a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs @@ -632,7 +632,7 @@ public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() try { - var result = await reviewer.ReviewWithDeltaAsync(path, "content"); + var result = await reviewer.ReviewWithDeltaAsync(path, "changed"); Assert.IsNotNull(result.review); Assert.AreEqual(path, result.review.FilePath); @@ -645,7 +645,7 @@ public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() submission.Document.FilePath == path && submission.Document.IsDirty && submission.RelPath == "test.cs" && - submission.Content == "content" && + submission.Content == "changed" && submission.UpdateDiagnosticsPane && submission.UpdateMonitor)), Times.Once); 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..eb65319e --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Services; +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 AnalysisBatchTracker_GrowsWhenRemainingIncreasesThenResets() + { + var tracker = new AnalysisBatchTracker(); + + var first = tracker.Update(2); + var second = tracker.Update(3); + var idle = tracker.Update(0); + + Assert.AreEqual(2, first.TotalCount); + Assert.AreEqual(2, first.RemainingCount); + Assert.AreEqual(3, second.TotalCount); + Assert.AreEqual(3, second.RemainingCount); + Assert.AreEqual(0, idle.TotalCount); + Assert.AreEqual(0, idle.RemainingCount); + } + + [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/ReviewPipelineOwnershipTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs new file mode 100644 index 00000000..3f7ffb12 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs @@ -0,0 +1,150 @@ +// 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 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 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 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/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/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index d8e61b91..8a849ed3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -315,15 +315,7 @@ public async Task DeltaReceived_UnknownPath_RefreshesInventory() client.Object, new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); - client.Raise( - x => x.DeltaReceived += null, - client.Object, - new DeltaNotification - { - RepoRoot = repo, - Path = "new.cs", - Result = new DeltaResponseModel(), - }); + await coordinator.AdmitsDeltaAsync(repo, "new.cs"); await WaitUntilAsync(() => coordinator.GetInventoryFiles(repo).Contains("new.cs")); client.Verify(x => x.GetWatchInventoryAsync(repo, It.IsAny()), Times.Once); @@ -356,14 +348,8 @@ public async Task DeltaReceived_TrackedOrDirtyPath_DoesNotRefreshInventory() client.Object, new WatchInventory { RepoRoot = repo, Files = new[] { "known.cs" } }); - client.Raise( - x => x.DeltaReceived += null, - client.Object, - new DeltaNotification { RepoRoot = repo, Path = "known.cs", Result = new DeltaResponseModel() }); - client.Raise( - x => x.DeltaReceived += null, - client.Object, - new DeltaNotification { RepoRoot = repo, Path = "dirty.cs", Result = new DeltaResponseModel() }); + 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); @@ -457,15 +443,10 @@ public async Task DeltaReceived_RepeatedUnknownPath_LogsRefreshFailure() x => x.WatchInventoryChanged += null, client.Object, new WatchInventory { RepoRoot = repo, Files = Array.Empty() }); - var delta = new DeltaNotification - { - RepoRoot = repo, - Path = "new.cs", - Result = new DeltaResponseModel(), - }; - client.Raise(x => x.DeltaReceived += null, client.Object, delta); - client.Raise(x => x.DeltaReceived += null, client.Object, delta); + 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( @@ -494,12 +475,9 @@ public async Task StopAll_CancelsPendingInventoryRefresh() x => x.WatchInventoryChanged += null, client.Object, new WatchInventory { RepoRoot = repo, Files = Array.Empty() }); - client.Raise( - x => x.DeltaReceived += null, - client.Object, - new DeltaNotification { RepoRoot = repo, Path = "new.cs", Result = new DeltaResponseModel() }); - + var admission = coordinator.AdmitsDeltaAsync(repo, "new.cs"); coordinator.StopAll(); + await admission; await Task.Delay(WorkspaceWatchCoordinator.InventoryRefreshDelayMs + 100); client.Verify( @@ -512,6 +490,67 @@ public async Task StopAll_CancelsPendingInventoryRefresh() } } + [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 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); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs index ed2abab9..2e08f6f8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs @@ -13,9 +13,9 @@ public class CliSettingsProvider : ICliSettingsProvider { // single point of truth for CLI version // used by the build pipeline to bundle the CLI with the extension - public string RequiredDevToolVersion => "8a7257420cc2dec1cf6ff7866db4da8c58f67602"; + public string RequiredDevToolVersion => "fbf6c11101be5b35288d64b9aee9a3c97573667a"; - public string RequiredCliBinarySha256 => "41b68ec72e3f50ba58b5292608d21b9d51670a5e85105868a6fcaa75a3883c02"; + public string RequiredCliBinarySha256 => "eff7759f0c23b54938df6bbf4ce0a11bd1690097ad9b167fe8df602dcb3d820e"; public string CliArtifactName => $"cs-ide-windows-amd64-{RequiredDevToolVersion}.zip"; 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 866ad420..ab8de41b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs @@ -9,6 +9,7 @@ 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.Interfaces.Telemetry; using Codescene.VSExtension.Core.Models; @@ -28,6 +29,7 @@ public class CodeReviewer : ICodeReviewer private readonly ICodeHealthMonitorNotifier _notifier; private readonly IPreflightManager _preflightManager; private readonly IReviewPipeline _pipeline; + private readonly ISettingsProvider _settings; public CodeReviewer( ILogger logger, @@ -37,7 +39,8 @@ public CodeReviewer( IGitService git, ICodeHealthMonitorNotifier notifier = null, IPreflightManager preflightManager = null, - IReviewPipeline pipeline = null) + IReviewPipeline pipeline = null, + ISettingsProvider settings = null) { _logger = logger; _mapper = mapper; @@ -47,6 +50,7 @@ public CodeReviewer( _notifier = notifier; _preflightManager = preflightManager; _pipeline = pipeline; + _settings = settings; } public async Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) @@ -158,7 +162,7 @@ public async Task GetOrComputeBaselineRawScoreAsync(string path, string RelPath = RpcPath.RelativePosix(repoRoot, path), Content = content, UpdateDiagnosticsPane = true, - UpdateMonitor = true, + UpdateMonitor = ShouldUpdateMonitor(path, content), }); if (cliReview == null) @@ -176,6 +180,33 @@ public async Task GetOrComputeBaselineRawScoreAsync(string path, string return (review, enriched); } + private bool ShouldUpdateMonitor(string path, string content) + { + if (!(_settings?.EnableAutomaticAnalysis ?? true)) + { + return false; + } + + return BufferDiffersFromDisk(path, content); + } + + private bool BufferDiffersFromDisk(string path, string content) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return true; + } + + try + { + return !string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal); + } + catch (Exception) + { + return true; + } + } + private async Task EnrichDeltaAsync(string path, string currentCode, DeltaResponseModel delta, CancellationToken cancellationToken) { if (_preflightManager == null || delta == null) 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..ffae6e6b --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs @@ -0,0 +1,205 @@ +// 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.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) + { + var identity = ParseIdentity(notification, "fileReview"); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + var result = normalized["result"]?.ToObject(Serializer); + _logger.Info("[cs-ide] received fileReview " + LogFields.Format( + LogFields.Field("id", identity.Item1 ?? "(none)"), + LogFields.Field("path", identity.Item2), + LogFields.Field("repo", identity.Item3)) + " " + IdeServerLog.ReviewSummary(result)); + ReviewReceived?.Invoke(this, new ReviewNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Result = result, + Queue = queue, + }); + } + + private void HandleDelta(JObject notification) + { + var identity = ParseIdentity(notification, "deltaReview"); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + var resultToken = normalized["result"]; + var delta = resultToken == null || resultToken.Type == JTokenType.Null + ? null + : resultToken.ToObject(Serializer); + _logger.Info("[cs-ide] received deltaReview " + LogFields.Format( + LogFields.Field("id", identity.Item1 ?? "(none)"), + LogFields.Field("path", identity.Item2), + LogFields.Field("repo", identity.Item3)) + " " + IdeServerLog.DeltaSummary(delta)); + DeltaReceived?.Invoke(this, new DeltaNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Result = delta, + Queue = queue, + }); + } + + private void HandleReviewFailure(JObject notification) + { + var identity = ParseIdentity(notification, "reviewFailed"); + if (identity == null) + { + return; + } + + var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); + var queue = ParseQueue(normalized, identity.Item3); + EmitQueue(queue); + ReviewFailed?.Invoke(this, new ReviewFailedNotification + { + Id = identity.Item1, + Path = identity.Item2, + RepoRoot = identity.Item3, + Message = (string)normalized["message"], + Queue = queue, + }); + } + + 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 ParseQueue(JObject notification, string repoRoot) + { + var queueToken = notification?["queue"] as JObject; + if (queueToken == null) + { + return null; + } + + var files = queueToken["files"] as JArray; + return new ReviewQueue + { + Count = queueToken["count"]?.Value() ?? 0, + Files = (files ?? new JArray()) + .Select(token => (string)token) + .Where(value => !string.IsNullOrEmpty(value)) + .Select(relPath => Path.Combine(repoRoot ?? string.Empty, relPath.Replace('/', Path.DirectorySeparatorChar))) + .ToList(), + }; + } + + private void EmitQueue(ReviewQueue queue) + { + if (queue == null) + { + return; + } + + QueueChanged?.Invoke(this, queue); + } + } +} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs index 4c8da196..6a9ae550 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -1,8 +1,9 @@ -// Copyright (c) CodeScene. All rights reserved. +// 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.Threading; @@ -16,6 +17,7 @@ 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; @@ -25,7 +27,7 @@ namespace Codescene.VSExtension.Core.Application.Cli { [Export(typeof(IIdeServerClient))] [PartCreationPolicy(CreationPolicy.Shared)] - public sealed class IdeServerClient : IIdeServerClient + public sealed partial class IdeServerClient : IIdeServerClient { private static readonly JsonSerializer Serializer = JsonSerializer.CreateDefault(); private readonly ICliSettingsProvider _cliSettingsProvider; @@ -40,6 +42,7 @@ public sealed class IdeServerClient : IIdeServerClient private JsonRpc _rpc; private IIdeServerProcess _process; private int _startCount; + private int _requestSeq; private bool _disposed; private bool _integrityVerified; private string _verifiedCliPath; @@ -96,6 +99,7 @@ public async Task StartAsync(CancellationToken cancellationToken public async Task RestartAsync(CancellationToken cancellationToken = default) { + _logger.Info("[cs-ide] restarting"); StopProcess(new Exception("CodeScene IDE server restarted")); await StartAsync(cancellationToken).ConfigureAwait(false); } @@ -179,7 +183,13 @@ public void ReviewFiles(string repoRoot, IReadOnlyList files) Content = file.Content, }).ToList(), }; - _logger.Info($"[cs-ide] sending reviewFiles count={payload.Files.Count}"); + 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)))); _ = SendNotificationAsync("cs-ide/reviewFiles", payload); } @@ -190,7 +200,9 @@ public void WatchFiles(string repoRoot, IReadOnlyList relativePaths = nu RepoRoot = repoRoot, RelativePaths = relativePaths?.ToArray(), }; - _logger.Debug($"[cs-ide] sending watchFiles repo-root={repoRoot} relative-paths={(relativePaths == null ? "(whole repository)" : string.Join(", ", relativePaths))}"); + _logger.Info("[cs-ide] sending watchFiles " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("relativePaths", relativePaths ?? (IReadOnlyList)new[] { "(whole repository)" }))); _ = SendNotificationAsync("cs-ide/watchFiles", payload); } @@ -198,9 +210,15 @@ public void StopWatchFiles(string repoRoot, IReadOnlyList relativePaths { 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, @@ -256,17 +274,7 @@ private async Task StartCoreAsync() try { - process = _processFactory.Start(cliFilePath, arguments); - 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); + process = StartLoggedProcess(cliFilePath, arguments); rpc = CreateRpc(process); rpc.AddLocalRpcTarget(_notifications); @@ -307,6 +315,28 @@ private async Task StartCoreAsync() } } + 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(); @@ -319,6 +349,27 @@ private JsonRpc CreateRpc(IIdeServerProcess process) } 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 (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; @@ -369,6 +420,7 @@ private async Task SendNotificationAsync(string method, object argument) } catch (Exception ex) { + _logger.Error("[cs-ide] notification failed method=" + method + ": " + ex.Message, ex); HandleError(ex); } } @@ -391,173 +443,6 @@ private object ToParameterObject(object argument) return obj.ToObject>(Serializer); } - 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; - ServerStarted?.Invoke(this, new ServerStartEvent { Metadata = parsed, Restart = restart }); - } - - private void HandleReview(JObject notification) - { - var identity = ParseIdentity(notification); - if (identity == null) - { - return; - } - - var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); - _logger.Info($"[cs-ide] received fileReview id={identity.Item1 ?? "(none)"} path={identity.Item2}"); - ReviewReceived?.Invoke(this, new ReviewNotification - { - Id = identity.Item1, - Path = identity.Item2, - RepoRoot = identity.Item3, - Result = normalized["result"]?.ToObject(Serializer), - Queue = queue, - }); - } - - private void HandleDelta(JObject notification) - { - var identity = ParseIdentity(notification); - if (identity == null) - { - return; - } - - var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); - var resultToken = normalized["result"]; - _logger.Info($"[cs-ide] received deltaReview id={identity.Item1 ?? "(none)"} path={identity.Item2}"); - DeltaReceived?.Invoke(this, new DeltaNotification - { - Id = identity.Item1, - Path = identity.Item2, - RepoRoot = identity.Item3, - Result = resultToken == null || resultToken.Type == JTokenType.Null - ? null - : resultToken.ToObject(Serializer), - Queue = queue, - }); - } - - private void HandleReviewFailure(JObject notification) - { - var identity = ParseIdentity(notification); - if (identity == null) - { - return; - } - - var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); - ReviewFailed?.Invoke(this, new ReviewFailedNotification - { - Id = identity.Item1, - Path = identity.Item2, - RepoRoot = identity.Item3, - Message = (string)normalized["message"], - Queue = queue, - }); - } - - private void HandleWatchInventory(JObject notification) - { - var inventory = ParseWatchInventory(notification, null); - if (string.IsNullOrEmpty(inventory.RepoRoot)) - { - return; - } - - _logger.Info($"[cs-ide] received watchInventoryChanged repo={inventory.RepoRoot} count={inventory.Files.Count}"); - WatchInventoryChanged?.Invoke(this, inventory); - } - - private Tuple ParseIdentity(JObject notification) - { - if (notification == null) - { - 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)) - { - 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 ParseQueue(JObject notification, string repoRoot) - { - var queueToken = notification?["queue"] as JObject; - if (queueToken == null) - { - return null; - } - - var files = queueToken["files"] as JArray; - return new ReviewQueue - { - Count = queueToken["count"]?.Value() ?? 0, - Files = (files ?? new JArray()) - .Select(token => (string)token) - .Where(value => !string.IsNullOrEmpty(value)) - .Select(relPath => Path.Combine(repoRoot ?? string.Empty, relPath.Replace('/', Path.DirectorySeparatorChar))) - .ToList(), - }; - } - - private void EmitQueue(ReviewQueue queue) - { - if (queue == null) - { - return; - } - - QueueChanged?.Invoke(this, queue); - } - private void EnsureCliIntegrity(string cliFilePath) { lock (_gate) 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..52296e61 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs @@ -0,0 +1,319 @@ +// 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/review", SummarizeReviewRequest }, + { "cs-ide/delta", SummarizeDeltaRequest }, + { "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/review", argument => ReviewSummary(argument as CliReviewModel) }, + { "cs-ide/delta", argument => DeltaSummary(argument as DeltaResponseModel) }, + { "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 SummarizeReviewRequest(object argument) + { + var review = argument as ReviewRequestModel; + if (review == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("path", review.FilePath), + LogFields.Field("hasContent", review.FileContent != null), + LogFields.Field("hasCachePath", review.CachePath != null)); + } + + private static string SummarizeDeltaRequest(object argument) + { + var delta = argument as DeltaRequestParams; + if (delta == null) + { + return string.Empty; + } + + return LogFields.Format( + LogFields.Field("hasOldScore", delta.OldScore != null), + LogFields.Field("hasNewScore", delta.NewScore != null)); + } + + 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/ReviewPipeline.Notifications.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs index fd7f5995..9ffa5bd1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -24,6 +24,11 @@ private void OnReviewReceived(object sender, ReviewNotification notification) if (string.IsNullOrEmpty(notification.Id)) { + if (!AutomaticAnalysisEnabled()) + { + return; + } + _ = PresentWatchReviewAsync(notification); return; } @@ -40,17 +45,48 @@ private void OnReviewReceived(object sender, ReviewNotification notification) if (!IsCurrent(pending, notification.RepoRoot, notification.Path) || !MatchesHash(notification.Result?.GitBlobSha, pending.ContentHash)) { - _logger?.Warn($"[pipeline] ignoring fileReview id={notification.Id} path={notification.Path} repo={notification.RepoRoot}"); + _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) @@ -60,7 +96,7 @@ private void OnDeltaReceived(object sender, DeltaNotification notification) if (string.IsNullOrEmpty(notification.Id)) { - _ = PresentWatchDeltaAsync(notification); + QueueWatchDelta(notification); return; } @@ -77,11 +113,17 @@ private void OnDeltaReceived(object sender, DeltaNotification notification) if (IsCurrent(pending, notification.RepoRoot, notification.Path) && MatchesHash(hash, pending.ContentHash)) { pending.DeltaResult = notification.Result; - _presentation.PresentDelta(ToPresentedDelta(pending, notification.Result)); + PresentCurrentDelta(pending, notification.Result); } else { - _logger?.Warn($"[pipeline] ignoring deltaReview id={notification.Id} path={notification.Path} repo={notification.RepoRoot}"); + _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); @@ -167,6 +209,15 @@ private async Task PresentWatchReviewAsync(ReviewNotification notification) 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) { @@ -251,13 +302,22 @@ private async Task WatchPresentationAsync(string repoRoot, str 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 = await CurrentShaAsync(document, filePath).ConfigureAwait(false); if (receivedSha != null && receivedSha != expectedSha) { - _logger?.Warn($"[pipeline] ignoring watch result path={posixPath} repo={repoRoot} stale sha"); + _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))); return null; } @@ -354,12 +414,48 @@ private void IgnorePending(PendingReview pending) private bool IsCurrent(PendingReview pending, string repoRoot, string relPath) { - return RpcPath.PathsEqual(pending.RepoRoot, repoRoot) - && RpcPath.ToPosixRelPath(pending.RelPath) == RpcPath.ToPosixRelPath(relPath) - && _latestByPath.TryGetValue(pending.PathKey, out var latest) - && ReferenceEquals(latest, pending) - && GitBlobSha.FromUtf8(pending.Document.Content) == pending.ContentHash - && (!_tombstones.TryGetValue(pending.PathKey, out var tombstone) || tombstone < pending.Generation); + 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 diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs index cf7aff9e..65d42fb0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -7,6 +7,7 @@ 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; @@ -24,7 +25,9 @@ public sealed partial class ReviewPipeline : IReviewPipeline, IDisposable 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); @@ -34,14 +37,16 @@ public sealed partial class ReviewPipeline : IReviewPipeline, IDisposable private int _dedupEpoch; 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) - : this(client, presentation, fileAccess, null, logger) + [Import(AllowDefault = true)] ILogger logger = null, + [Import(AllowDefault = true)] ISettingsProvider settings = null) + : this(client, presentation, fileAccess, null, logger, settings) { } @@ -50,13 +55,15 @@ internal ReviewPipeline( IReviewPipelinePresentation presentation, IReviewPipelineFileAccess fileAccess, Func createId, - ILogger logger) + 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; @@ -100,16 +107,64 @@ public void Remove(string repoRoot, params ReviewDocument[] documents) _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) + { + 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); } } @@ -127,6 +182,7 @@ public void Reset() _tombstones.Clear(); _watchReviews.Clear(); _watchReviewOrder.Clear(); + _bufferOwnedPaths.Clear(); } } @@ -159,6 +215,7 @@ public void Dispose() _tombstones.Clear(); _watchReviews.Clear(); _watchReviewOrder.Clear(); + _bufferOwnedPaths.Clear(); } } @@ -172,6 +229,11 @@ private static string PathKey(string repoRoot, string relPath) return RpcPath.NormalizeFsPath(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); @@ -199,6 +261,11 @@ private static T CopySubmission(PendingReview pending) }; } + private bool AutomaticAnalysisEnabled() + { + return _settings?.EnableAutomaticAnalysis ?? true; + } + private Task<(CliReviewModel Review, DeltaResponseModel Delta)[]> SubmitBatchInternalAsync(string repoRoot, IReadOnlyList submissions) { var newReviews = new List(); @@ -212,7 +279,14 @@ private static T CopySubmission(PendingReview pending) var submission = submissions[i]; if (submission.Content == null) { - diskFiles.Add(new ReviewFile { RelPath = RpcPath.ToPosixRelPath(submission.RelPath) }); + 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))); tasks[i] = Task.FromResult<(CliReviewModel, DeltaResponseModel)>((null, null)); continue; } @@ -235,6 +309,11 @@ private static T CopySubmission(PendingReview pending) .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); } @@ -269,7 +348,13 @@ private PendingReview PrepareSubmission(string repoRoot, ReviewSubmission submis _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; } @@ -285,8 +370,16 @@ private PendingReview PrepareSubmission(string repoRoot, ReviewSubmission submis } 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; @@ -384,11 +477,27 @@ private void ReplayReview(PendingReview pending, bool replay) 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) 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..b037cad3 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs @@ -0,0 +1,226 @@ +// 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; + Task refresh; + lock (_gate) + { + repoRoot = ResolveRepoRoot(reportedRoot); + posixPath = RpcPath.ToPosixRelPath(relPath); + key = RpcPath.NormalizeFsPath(repoRoot); + if (!_inventories.ContainsKey(key)) + { + _logger?.Debug("[watch] delta admitted " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("path", posixPath), + LogFields.Field("reason", "no-inventory"))); + return true; + } + + if (IsMonitoredPathUnlocked(repoRoot, posixPath)) + { + return true; + } + + _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); + } + + await refresh.ConfigureAwait(false); + lock (_gate) + { + var admitted = _enabled && IsMonitoredPathUnlocked(repoRoot, posixPath); + if (!admitted) + { + _logger?.Debug("[watch] delta rejected " + LogFields.Format( + LogFields.Field("repo", repoRoot), + LogFields.Field("path", posixPath), + LogFields.Field("reason", "not-in-inventory"))); + } + + return admitted; + } + } + + private void OnQueueChanged(object sender, ReviewQueue queue) + { + lock (_gate) + { + _queueIdleTimer?.Dispose(); + _queueIdleTimer = null; + if (!ShouldPruneWhenIdle(queue)) + { + return; + } + + _queueIdleTimer = new Timer( + _ => + { + lock (_gate) + { + _queueIdleTimer?.Dispose(); + _queueIdleTimer = null; + if (_disposed || !_enabled) + { + return; + } + + var keep = PathsToKeepUnlocked(); + _host?.PruneMonitor(_inventories.Keys.Select(ResolveRepoRoot).ToList(), keep); + _logger?.Debug("[watch] queue idle, monitor re-pruned " + LogFields.Format( + LogFields.Field("repos", _inventories.Count), + LogFields.Field("keepPaths", keep.Count))); + } + }, + 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 IsMonitoredPathUnlocked(string repoRoot, string relPath) + { + var key = RpcPath.NormalizeFsPath(repoRoot); + if (_inventories.TryGetValue(key, out var inventory) && inventory.Contains(relPath)) + { + return true; + } + + return IsDirty(repoRoot, 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 index f940cfac..23aa396b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -10,6 +10,7 @@ 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; @@ -18,9 +19,10 @@ namespace Codescene.VSExtension.Core.Application.Git { [Export(typeof(IWorkspaceWatchCoordinator))] [PartCreationPolicy(CreationPolicy.Shared)] - public sealed class WorkspaceWatchCoordinator : IWorkspaceWatchCoordinator, IDisposable + 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; @@ -28,8 +30,11 @@ public sealed class WorkspaceWatchCoordinator : IWorkspaceWatchCoordinator, IDis 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 _refreshTimers = 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 Timer _queueIdleTimer; + private volatile bool _enabled = true; private bool _disposed; [ImportingConstructor] @@ -37,15 +42,18 @@ public WorkspaceWatchCoordinator( IIdeServerClient client, IReviewPipeline pipeline, ILogger logger, - [Import(AllowDefault = true)] IWorkspaceWatchHost host = null) + [Import(AllowDefault = true)] IWorkspaceWatchHost host = null, + [Import(AllowDefault = true)] ISettingsProvider settings = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); _logger = logger; _host = host; + _enabled = settings?.EnableAutomaticAnalysis ?? true; + _pipeline.SetWatchDeltaFilter((repoRoot, relPath) => AdmitsDeltaAsync(repoRoot, relPath)); _client.WatchInventoryChanged += OnWatchInventoryChanged; _client.ServerStarted += OnServerStarted; - _client.DeltaReceived += OnDeltaReceived; + _client.QueueChanged += OnQueueChanged; } public Task SyncAsync(IReadOnlyList workspaceDirectories) @@ -55,7 +63,14 @@ public Task SyncAsync(IReadOnlyList workspaceDirectories) return Task.CompletedTask; } - var targets = ResolveTargets(workspaceDirectories ?? Array.Empty()); + _workspaceDirectories = workspaceDirectories ?? Array.Empty(); + if (!_enabled) + { + return Task.CompletedTask; + } + + var targets = ResolveTargets(_workspaceDirectories); + _logger?.Debug("[watch] syncing " + LogFields.Format(LogFields.Field("count", targets.Count))); lock (_gate) { _knownRoots.Clear(); @@ -82,6 +97,25 @@ public Task SyncAsync(IReadOnlyList workspaceDirectories) return Task.CompletedTask; } + 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) @@ -93,12 +127,7 @@ public void StopAll() _inventories.Clear(); _knownRoots.Clear(); - foreach (var timer in _refreshTimers.Values) - { - timer.Dispose(); - } - - _refreshTimers.Clear(); + CancelPendingRefreshesUnlocked(); _pipeline.SetActiveRepos(Array.Empty()); } @@ -129,17 +158,8 @@ public void Dispose() _disposed = true; _client.WatchInventoryChanged -= OnWatchInventoryChanged; _client.ServerStarted -= OnServerStarted; - _client.DeltaReceived -= OnDeltaReceived; + _client.QueueChanged -= OnQueueChanged; StopAll(); - lock (_gate) - { - foreach (var timer in _refreshTimers.Values) - { - timer.Dispose(); - } - - _refreshTimers.Clear(); - } } private static List ResolveTargets(IReadOnlyList directories) @@ -202,10 +222,19 @@ private void EnsureWatch(string repoRoot, IReadOnlyList relativePaths) { 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))); Seed(repoRoot); return; } + _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(); @@ -222,6 +251,9 @@ private void StopWatchingUnlocked(string normalizedRoot) } 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(); @@ -230,7 +262,7 @@ private void StopWatchingUnlocked(string normalizedRoot) private void OnWatchInventoryChanged(object sender, WatchInventory inventory) { - if (inventory == null) + if (inventory == null || !_enabled) { return; } @@ -249,83 +281,23 @@ private void OnWatchInventoryChanged(object sender, WatchInventory inventory) private void ApplyInventoryUnlocked(string repoRoot, IReadOnlyList files) { + if (!_enabled) + { + return; + } + var resolved = ResolveRepoRoot(repoRoot); var key = RpcPath.NormalizeFsPath(resolved); var relPaths = new HashSet(files.Select(RpcPath.ToPosixRelPath), StringComparer.Ordinal); _inventories[key] = relPaths; var keep = PathsToKeepUnlocked(); + _logger?.Debug("[watch] inventory applied " + LogFields.Format( + LogFields.Field("repo", resolved), + LogFields.Field("files", relPaths.Count), + LogFields.Field("keepPaths", keep.Count))); _host?.PruneMonitor(_inventories.Keys.Select(ResolveRepoRoot).ToList(), keep); } - private void OnDeltaReceived(object sender, DeltaNotification notification) - { - if (notification?.Result == null) - { - return; - } - - lock (_gate) - { - var repoRoot = ResolveRepoRoot(notification.RepoRoot); - var key = RpcPath.NormalizeFsPath(repoRoot); - if (!_inventories.TryGetValue(key, out var inventory)) - { - return; - } - - var relPath = RpcPath.ToPosixRelPath(notification.Path); - if (inventory.Contains(relPath) || IsDirty(repoRoot, relPath)) - { - return; - } - - ScheduleInventoryRefresh(repoRoot, key); - } - } - - private void ScheduleInventoryRefresh(string repoRoot, string key) - { - if (_refreshTimers.TryGetValue(key, out var existing)) - { - existing.Change(InventoryRefreshDelayMs, Timeout.Infinite); - return; - } - - _refreshTimers[key] = new Timer( - _ => - { - lock (_gate) - { - if (_refreshTimers.TryGetValue(key, out var timer)) - { - timer.Dispose(); - _refreshTimers.Remove(key); - } - } - - _ = RefreshInventoryAsync(repoRoot); - }, - null, - InventoryRefreshDelayMs, - Timeout.Infinite); - } - - private async Task RefreshInventoryAsync(string 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 for {repoRoot}: {ex.Message}"); - } - } - private void OnServerStarted(object sender, ServerStartEvent startEvent) { if (!ShouldRestoreWatches(startEvent)) @@ -349,11 +321,21 @@ private bool ShouldRestoreWatches(ServerStartEvent startEvent) 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) { 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/AnalysisBatchTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs new file mode 100644 index 00000000..b1a85e08 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs @@ -0,0 +1,37 @@ +// Copyright (c) CodeScene. All rights reserved. + +namespace Codescene.VSExtension.Core.Application.Services +{ + public sealed class AnalysisBatchTracker + { + private int _total; + private int _lastRemaining; + + public (int TotalCount, int RemainingCount) Update(int remaining) + { + if (remaining <= 0) + { + Reset(); + return (0, 0); + } + + if (_total == 0) + { + _total = remaining; + } + else if (remaining > _lastRemaining) + { + _total += remaining - _lastRemaining; + } + + _lastRemaining = remaining; + return (_total, remaining); + } + + public void Reset() + { + _total = 0; + _lastRemaining = 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 847207f1..aa7326be 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -19,14 +19,51 @@ namespace Codescene.VSExtension.Core.Application.Services public class CodeHealthMonitorNotifier : ICodeHealthMonitorNotifier { private readonly ConcurrentDictionary _activeJobs = new ConcurrentDictionary(); + private readonly AnalysisBatchTracker _batch = new AnalysisBatchTracker(); 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); @@ -60,6 +97,12 @@ public void ApplyQueue(ReviewQueue queue) { lock (_lock) { + if (!_automaticAnalysisEnabled) + { + SetStoppedUnlocked(); + return; + } + _activeJobs.Clear(); var files = queue?.Files ?? Array.Empty(); var jobs = new List(); @@ -77,6 +120,11 @@ public void ApplyQueue(ReviewQueue queue) } DeltaJobTracker.Replace(jobs); + var remaining = Math.Max(jobs.Count, queue?.Count ?? 0); + var progress = _batch.Update(remaining); + RemainingCount = progress.RemainingCount; + TotalCount = progress.TotalCount > 0 ? progress.TotalCount : (int?)null; + AnalysisState = RemainingCount > 0 ? "running" : "idle"; ViewUpdateRequested?.Invoke(this, EventArgs.Empty); } } @@ -87,6 +135,14 @@ public void Clear() { _activeJobs.Clear(); DeltaJobTracker.Clear(); + _batch.Reset(); + if (_automaticAnalysisEnabled) + { + AnalysisState = "idle"; + TotalCount = null; + RemainingCount = 0; + } + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); } } @@ -95,5 +151,16 @@ public void RequestViewUpdate() { ViewUpdateRequested?.Invoke(this, EventArgs.Empty); } + + private void SetStoppedUnlocked() + { + _activeJobs.Clear(); + DeltaJobTracker.Clear(); + _batch.Reset(); + AnalysisState = "stopped"; + TotalCount = null; + RemainingCount = 0; + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); + } } } 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..487f8cae 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/DebounceService.cs @@ -68,6 +68,21 @@ public void Debounce(string key, Action action, TimeSpan delay) } } + public void Cancel(string key) + { + lock (_lock) + { + if (!_timers.TryGetValue(key, out var existingCts)) + { + return; + } + + existingCts.Cancel(); + existingCts.Dispose(); + _timers.Remove(key); + } + } + public void Dispose() { lock (_lock) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs index fc61b7d9..4778e6a7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Consts/WebComponentConstants.cs @@ -49,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/Interfaces/Cli/IReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs index 010a2242..4e9e9b07 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IReviewPipeline.cs @@ -1,5 +1,6 @@ // Copyright (c) CodeScene. All rights reserved. +using System; using System.Collections.Generic; using System.Threading.Tasks; using Codescene.VSExtension.Core.Models.Cli.Delta; @@ -16,6 +17,12 @@ public interface IReviewPipeline 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(); 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 90f85b4c..222e9522 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs @@ -9,5 +9,7 @@ public interface ISettingsProvider string AuthToken { get; } int ServerWorkerThreads { get; } + + bool EnableAutomaticAnalysis { get; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs index d1ae31a2..4334dce1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IWorkspaceWatchCoordinator.cs @@ -11,6 +11,8 @@ public interface IWorkspaceWatchCoordinator void StopAll(); + void SetEnabled(bool enabled); + IReadOnlyCollection GetInventoryFiles(string repoRoot); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs index 658d6214..37952d45 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/ICodeHealthMonitorNotifier.cs @@ -6,6 +6,12 @@ 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); @@ -15,5 +21,7 @@ public interface ICodeHealthMonitorNotifier void ApplyQueue(ReviewQueue queue); void Clear(); + + void SetAutomaticAnalysisEnabled(bool enabled); } } 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..38f6f45b 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,7 @@ namespace Codescene.VSExtension.Core.Interfaces.Util public interface IDebounceService { void Debounce(string key, Action action, TimeSpan delay); + + void Cancel(string key); } } 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 2b46404b..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,7 +25,7 @@ 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. 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/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/LogFields.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs new file mode 100644 index 00000000..746a0d44 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs @@ -0,0 +1,89 @@ +// 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 bool || value is byte || value is short || value is int || value is long + || value is float || value is double || value is decimal) + { + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + 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/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/Cli/CachingCodeReviewerProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs index 876210cc..aa16de0d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -9,6 +9,7 @@ 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.Interfaces.Telemetry; using Codescene.VSExtension.Core.Models; @@ -37,7 +38,8 @@ public CachingCodeReviewerProvider( IPreflightManager preflightManager, IReviewPipeline pipeline, IIdeServerClient client, - CodeHealthMonitorNotifier notifier) + CodeHealthMonitorNotifier notifier, + ISettingsProvider settings) { _scheduler = scheduler; _notifier = notifier; @@ -47,7 +49,8 @@ public CachingCodeReviewerProvider( _notifier.ApplyQueue(queue); }); client.QueueChanged += (_, queue) => _queueProgress.Update(queue); - _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, preflightManager, pipeline); + _notifier.SetAutomaticAnalysisEnabled(settings?.EnableAutomaticAnalysis ?? true); + _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, preflightManager, pipeline, settings); } public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs new file mode 100644 index 00000000..18de99bf --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs @@ -0,0 +1,34 @@ +// Copyright (c) CodeScene. All rights reserved. + +using System.Threading.Tasks; +using Codescene.VSExtension.VS2022.Options; +using Community.VisualStudio.Toolkit; +using Microsoft.VisualStudio.Shell; + +namespace Codescene.VSExtension.VS2022.Commands +{ + [Command(PackageGuids.CodeSceneCmdSetString, PackageIds.StopAutomaticAnalysis)] + internal sealed class StopAutomaticAnalysisCommand : BaseCommand + { + protected override Task ExecuteAsync(OleMenuCmdEventArgs e) + { + return SetAutomaticAnalysisAsync(false); + } + + internal static async Task SetAutomaticAnalysisAsync(bool enabled) + { + var general = General.Instance; + general.EnableAutomaticAnalysis = enabled; + await general.SaveAsync(); + } + } + + [Command(PackageGuids.CodeSceneCmdSetString, PackageIds.StartAutomaticAnalysis)] + internal sealed class StartAutomaticAnalysisCommand : BaseCommand + { + protected override Task ExecuteAsync(OleMenuCmdEventArgs e) + { + return StopAutomaticAnalysisCommand.SetAutomaticAnalysisAsync(true); + } + } +} 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 c7e0ec1c..a56910ee 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; @@ -30,6 +31,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; @@ -77,7 +80,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; } @@ -85,13 +88,16 @@ 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 += (_, _) => @@ -113,22 +119,93 @@ public void TextViewCreated(IWpfTextView textView) textView.Closed += (_, _) => { - _logger.Debug($"File closed: {filePath}..."); - var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); - if (!string.IsNullOrEmpty(repoRoot) && _reviewPipeline != null) + openViews.Value--; + if (openViews.Value > 0) + { + return; + } + + _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) { - _reviewPipeline.Remove(repoRoot, new ReviewDocument { FilePath = filePath, Content = string.Empty }); + 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 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) @@ -216,6 +293,11 @@ private async Task ReviewContentAsync(string path, ITextBuffer buffer) await ApplyReviewResultsAsync(result, buffer); await _aceRefactorService.CheckContainsRefactorableFunctionsAsync(result, code); _aceRefactorSuggestedActionsNotifier.NotifyAceRefactorableDataChanged(buffer); + if (!HasOpenViews(buffer)) + { + ClearEditorDiagnostics(path); + RestoreOwnedBuffer(path); + } } catch (Exception e) { @@ -248,5 +330,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/Options/General.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs index 2f49c0d1..f62a3d67 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; } @@ -66,6 +71,11 @@ public General() [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 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(); @@ -106,5 +116,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 a2c2b528..f6ec0488 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs @@ -14,5 +14,7 @@ public class GeneralSettingsProvider : ISettingsProvider public string AuthToken => General.Instance.AuthToken; public int ServerWorkerThreads => General.Instance.ServerWorkerThreads; + + public bool EnableAutomaticAnalysis => General.Instance.EnableAutomaticAnalysis; } } 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..2803b107 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 @@ -131,7 +131,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; } } @@ -302,6 +302,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 +327,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/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 815441a1..04b9d691 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -72,7 +72,7 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Cli file await CheckCliFileAsync(); - await StartIdeServerAsync(cancellationToken); + JoinableTaskFactory.RunAsync(() => StartIdeServerAsync(cancellationToken)).FileAndForget("VS2022Package/StartIdeServer"); // Subscribe on active document change event await SubscribeOnActiveWindowChangeAsync(); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs index dd0d65cf..c670f882 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs @@ -33,6 +33,8 @@ internal sealed partial class PackageIds public const int CodeSceneCommandsGroup = 0x1022; public const int OpenSettings = 0x1025; public const int CopyDeviceId = 0x1026; + public const int StopAutomaticAnalysis = 0x1028; + public const int StartAutomaticAnalysis = 0x1029; //public const int RevokeTerms = 0x1027; public const int SignInCommand = 0x0400; public const int OpenStatusWindowCommand = 0x0800; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct index 4f38a6c9..4fd2a33e 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct @@ -71,6 +71,18 @@ Settings + + -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; @@ -34,6 +38,8 @@ public class SolutionEventsHandler : IVsSolutionEvents, IDisposable 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. @@ -62,7 +68,7 @@ public async Task InitializeAsync(IServiceProvider serviceProvider) VS.Events.SolutionEvents.OnAfterOpenFolder += SolutionEvents_OnAfterOpenFolder; } - _scheduler.Schedule(ct => EnsureSolutionInitializationAsync()); + ScheduleWorkspaceInitialization(null); } /// @@ -110,7 +116,7 @@ public int OnAfterCloseSolution(object pUnkReserved) public int OnAfterOpenSolution(object pUnkReserved, int fNewSolution) { - _scheduler.Schedule(ct => EnsureSolutionInitializationAsync()); + ScheduleWorkspaceInitialization(null); return VSConstants.S_OK; } @@ -119,6 +125,8 @@ public int OnBeforeCloseSolution(object pUnkReserved) lock (_initGate) { _solutionInitializationTask = null; + _initializedWorkspacePath = null; + _initGeneration++; } _branchWatcher?.Dispose(); @@ -132,6 +140,12 @@ public int OnBeforeCloseSolution(object pUnkReserved) public int OnAfterOpenProject(IVsHierarchy pHierarchy, int fAdded) { + if (_workspaceWatch == null) + { + ScheduleWorkspaceInitialization(null); + return VSConstants.S_OK; + } + _scheduler.Schedule(ct => RefreshWorkspacePathsAsync()); return VSConstants.S_OK; } @@ -152,6 +166,31 @@ 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() + { + } + public void Dispose() { ThreadHelper.ThrowIfNotOnUIThread(); @@ -180,10 +219,35 @@ private void Log(Func logAction) }); } - private void SolutionEvents_OnAfterOpenFolder(string obj) + private void ScheduleWorkspaceInitialization(string explicitPath) { - ThreadHelper.ThrowIfNotOnUIThread(); - OnAfterOpenSolution(null, 0); + _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) @@ -198,25 +262,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; } } @@ -224,13 +308,21 @@ 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(); - _codeHealthMonitorNotifier?.Clear(); + if (!IsCurrentInitialization(generation)) + { + return; + } + + ResetWorkspaceForInitialization(); + ClearAnalysisCaches(); + _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); + + if (!IsCurrentInitialization(generation)) + { + return; + } await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); _errorListWindowHandler?.ClearAll(); @@ -239,32 +331,26 @@ private async Task RunSolutionInitializationAsync(string solutionPath) _branchWatcher.StartWatching(solutionPath, (newBranch) => _scheduler.Schedule(ct2 => OnBranchChangedAsync(newBranch))); - await InitializeWorkspaceWatchAsync(solutionPath); + await InitializeWorkspaceWatchAsync(solutionPath, folderWorkspace, generation); } - private async Task InitializeWorkspaceWatchAsync(string solutionPath) + private async Task InitializeWorkspaceWatchAsync(string solutionPath, bool folderWorkspace, int generation) { try { - _workspaceWatch = await VS.GetMefServiceAsync(); - if (_workspaceWatch == null) + var coordinator = await PrepareWorkspaceWatchAsync(generation); + if (coordinator == null) { - Log(logger => - { - logger.Warn("Failed to obtain IWorkspaceWatchCoordinator service."); - return Task.CompletedTask; - }); return; } - IReadOnlyCollection workspacePaths = null; - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - if (_solution != null) + var workspacePaths = await ResolveWorkspacePathsAsync(solutionPath, folderWorkspace, generation); + if (workspacePaths == null) { - workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); + return; } - await _workspaceWatch.SyncAsync(workspacePaths == null ? Array.Empty() : new List(workspacePaths)); + await coordinator.SyncAsync(new List(workspacePaths)); Log(logger => { @@ -282,6 +368,53 @@ private async Task InitializeWorkspaceWatchAsync(string solutionPath) } } + 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 (_workspaceWatch == null) @@ -291,18 +424,31 @@ private async Task RefreshWorkspacePathsAsync() var solution = await VS.Solutions.GetCurrentSolutionAsync(); var solutionPath = solution?.FullPath; + if (string.IsNullOrEmpty(solutionPath)) + { + solutionPath = _initializedWorkspacePath; + } + if (string.IsNullOrEmpty(solutionPath)) { return; } await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - if (_solution == null) + IReadOnlyCollection workspacePaths; + if (Directory.Exists(solutionPath)) + { + workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(null, solutionPath); + } + else if (_solution != null) + { + workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); + } + else { return; } - var workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); await _workspaceWatch.SyncAsync(workspacePaths == null ? Array.Empty() : new List(workspacePaths)); } @@ -330,4 +476,52 @@ 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 ResetWorkspaceForInitialization() + { + _branchWatcher?.Dispose(); + _branchWatcher = null; + _workspaceWatch?.StopAll(); + _reviewPipeline?.Reset(); + _reviewPipeline?.SetActiveRepos(Array.Empty()); + } + + private void ClearAnalysisCaches() + { + new DeltaCacheService().Clear(); + new BaselineReviewCacheService().Clear(); + new ReviewCacheService().Clear(); + new AceRefactorableFunctionsCacheService().Clear(); + _codeHealthMonitorNotifier?.Clear(); + } } 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..466e2244 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs @@ -40,7 +40,7 @@ public static async Task UpdateViewAsync() if (_updateViewDebounceService != null && _scheduler != null) { - _updateViewDebounceService.Debounce( + _updateViewDebounceService.Throttle( "CodeSceneToolWindow.UpdateView", () => _scheduler.Schedule(ct => RunActualUpdateAsync()), TimeSpan.FromMilliseconds(UpdateViewDebounceMs)); From 7cffb38c229ab0775fad9d4fac21155f91ce4f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 2 Oct 2026 14:31:14 +0200 Subject: [PATCH 10/38] fix: avoid UI deadlocks when opening the monitor and a solution The tool window was created during package initialization, and solution load waited on the watch lock while that lock waited for the UI thread. --- .../WorkspaceWatchCoordinator.Admission.cs | 103 +++++++++++------ .../Git/WorkspaceWatchCoordinator.cs | 108 ++++++++++++------ .../WebComponent/CodeSceneToolWindow.cs | 37 +++++- .../VS2022Package.cs | 7 ++ 4 files changed, 181 insertions(+), 74 deletions(-) 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 index b037cad3..7f04a7d5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.Admission.cs @@ -22,7 +22,32 @@ internal async Task AdmitsDeltaAsync(string reportedRoot, string relPath) string repoRoot; string posixPath; string key; - Task refresh; + 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); @@ -30,16 +55,34 @@ internal async Task AdmitsDeltaAsync(string reportedRoot, string relPath) key = RpcPath.NormalizeFsPath(repoRoot); if (!_inventories.ContainsKey(key)) { - _logger?.Debug("[watch] delta admitted " + LogFields.Format( - LogFields.Field("repo", repoRoot), - LogFields.Field("path", posixPath), - LogFields.Field("reason", "no-inventory"))); + 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); + } - if (IsMonitoredPathUnlocked(repoRoot, posixPath)) + private bool TryScheduleRefresh(string repoRoot, string posixPath, string key, out Task refresh) + { + lock (_gate) + { + if (InventoryContainsUnlocked(repoRoot, posixPath)) { - return true; + refresh = Task.CompletedTask; + return false; } _logger?.Debug("[watch] delta reconcile scheduled " + LogFields.Format( @@ -47,22 +90,19 @@ internal async Task AdmitsDeltaAsync(string reportedRoot, string relPath) LogFields.Field("path", posixPath), LogFields.Field("reason", "not-in-inventory"))); refresh = ScheduleInventoryRefreshUnlocked(repoRoot, key); + return true; } + } - await refresh.ConfigureAwait(false); + private bool AdmittedAfterRefresh(string repoRoot, string posixPath) + { + var inInventory = false; lock (_gate) { - var admitted = _enabled && IsMonitoredPathUnlocked(repoRoot, posixPath); - if (!admitted) - { - _logger?.Debug("[watch] delta rejected " + LogFields.Format( - LogFields.Field("repo", repoRoot), - LogFields.Field("path", posixPath), - LogFields.Field("reason", "not-in-inventory"))); - } - - return admitted; + inInventory = _enabled && InventoryContainsUnlocked(repoRoot, posixPath); } + + return inInventory || IsDirty(repoRoot, posixPath); } private void OnQueueChanged(object sender, ReviewQueue queue) @@ -79,20 +119,18 @@ private void OnQueueChanged(object sender, ReviewQueue queue) _queueIdleTimer = new Timer( _ => { + var prune = false; lock (_gate) { _queueIdleTimer?.Dispose(); _queueIdleTimer = null; - if (_disposed || !_enabled) - { - return; - } - - var keep = PathsToKeepUnlocked(); - _host?.PruneMonitor(_inventories.Keys.Select(ResolveRepoRoot).ToList(), keep); - _logger?.Debug("[watch] queue idle, monitor re-pruned " + LogFields.Format( - LogFields.Field("repos", _inventories.Count), - LogFields.Field("keepPaths", keep.Count))); + prune = !_disposed && _enabled; + } + + if (prune) + { + PruneMonitor(); + _logger?.Debug("[watch] queue idle, monitor re-pruned"); } }, null, @@ -137,15 +175,10 @@ private void PauseWatching() _host?.PruneMonitor(Array.Empty(), new HashSet(StringComparer.OrdinalIgnoreCase)); } - private bool IsMonitoredPathUnlocked(string repoRoot, string relPath) + private bool InventoryContainsUnlocked(string repoRoot, string relPath) { var key = RpcPath.NormalizeFsPath(repoRoot); - if (_inventories.TryGetValue(key, out var inventory) && inventory.Contains(relPath)) - { - return true; - } - - return IsDirty(repoRoot, relPath); + return _inventories.TryGetValue(key, out var inventory) && inventory.Contains(relPath); } private Task ScheduleInventoryRefreshUnlocked(string repoRoot, string key) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs index 23aa396b..b222ebab 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -71,22 +71,9 @@ public Task SyncAsync(IReadOnlyList workspaceDirectories) var targets = ResolveTargets(_workspaceDirectories); _logger?.Debug("[watch] syncing " + LogFields.Format(LogFields.Field("count", targets.Count))); - lock (_gate) + if (ReplaceWatchedRoots(targets)) { - _knownRoots.Clear(); - foreach (var target in targets) - { - _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); - } - } + PruneMonitor(); } foreach (var target in targets) @@ -214,6 +201,31 @@ private static bool CoversEntireRepo(IReadOnlyList relative) return relative.Count == 0 || relative.Any(path => string.IsNullOrEmpty(path) || path == "."); } + private bool ReplaceWatchedRoots(IReadOnlyList targets) + { + var stopped = false; + lock (_gate) + { + _knownRoots.Clear(); + foreach (var target in targets) + { + _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) { var key = RpcPath.NormalizeFsPath(repoRoot); @@ -227,17 +239,17 @@ private void EnsureWatch(string repoRoot, IReadOnlyList relativePaths) LogFields.Field("repo", repoRoot), LogFields.Field("reason", "scope-unchanged"), LogFields.Field("scopeKey", scopeKey))); - Seed(repoRoot); - return; } - - _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(); + else + { + _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(); + } } Seed(repoRoot); @@ -267,6 +279,7 @@ private void OnWatchInventoryChanged(object sender, WatchInventory inventory) return; } + var applied = false; lock (_gate) { var key = RpcPath.NormalizeFsPath(ResolveRepoRoot(inventory.RepoRoot)); @@ -275,27 +288,53 @@ private void OnWatchInventoryChanged(object sender, WatchInventory inventory) return; } - ApplyInventoryUnlocked(inventory.RepoRoot, inventory.Files ?? Array.Empty()); + applied = ApplyInventoryUnlocked(inventory.RepoRoot, inventory.Files ?? Array.Empty()); + } + + if (applied) + { + PruneMonitor(); } } - private void ApplyInventoryUnlocked(string repoRoot, IReadOnlyList files) + private bool ApplyInventoryUnlocked(string repoRoot, IReadOnlyList files) { if (!_enabled) { - return; + return false; } var resolved = ResolveRepoRoot(repoRoot); var key = RpcPath.NormalizeFsPath(resolved); var relPaths = new HashSet(files.Select(RpcPath.ToPosixRelPath), StringComparer.Ordinal); _inventories[key] = relPaths; - var keep = PathsToKeepUnlocked(); _logger?.Debug("[watch] inventory applied " + LogFields.Format( LogFields.Field("repo", resolved), - LogFields.Field("files", relPaths.Count), - LogFields.Field("keepPaths", keep.Count))); - _host?.PruneMonitor(_inventories.Keys.Select(ResolveRepoRoot).ToList(), keep); + 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()) + { + keep.Add(document.FilePath); + } + + _host?.PruneMonitor(repoRoots, keep); } private void OnServerStarted(object sender, ServerStartEvent startEvent) @@ -394,11 +433,6 @@ private ISet PathsToKeepUnlocked() } } - foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) - { - keep.Add(document.FilePath); - } - return keep; } 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 466e2244..e6c68036 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); @@ -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.Initialized; + 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/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 04b9d691..928d9cb8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -39,6 +39,7 @@ namespace Codescene.VSExtension.VS2022; [ProvideToolWindow(typeof(CodeSceneToolWindow.Pane), Style = VsDockStyle.Tabbed, Window = WindowGuids.SolutionExplorer)] public sealed class VS2022Package : ToolkitPackage { + private readonly TaskCompletionSource _initialized = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); private ILogger _logger; private SolutionEventsHandler _solutionEventsHandler; private IAsyncTaskScheduler _scheduler; @@ -48,6 +49,8 @@ public sealed class VS2022Package : ToolkitPackage public CancellationToken PackageDisposalToken => DisposalToken; + internal Task Initialized => _initialized.Task; + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { Instance = this; @@ -103,6 +106,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.TrySetResult(null); + } } protected override void Dispose(bool disposing) From 8a890dac8c507598d1b69cce5a8451d30dd6eedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 2 Oct 2026 18:25:55 +0200 Subject: [PATCH 11/38] feat: replace the terms banner with a default-on telemetry setting Show a one-time startup notice and stop telemetry calls when the setting is off, without waiting for the user to dismiss it. --- .../BenchmarkSupport.cs | 2 + .../TestSettingsProvider.cs | 2 + .../TestHarnessServices.cs | 2 + .../TelemetryManagerTests.cs | 19 ++ .../Application/Telemetry/TelemetryManager.cs | 24 ++- .../Interfaces/Extension/ISettingsProvider.cs | 2 + .../Codescene.VSExtension.VS2022.csproj | 2 +- .../EditorDocumentLifecycleHandler.cs | 12 -- .../Options/General.cs | 5 + .../Options/GeneralSettingsProvider.cs | 2 + .../Telemetry/TelemetryNoticeService.cs | 162 ++++++++++++++ .../TermsAndPoliciesService.cs | 203 ------------------ .../VS2022Package.cs | 19 +- 13 files changed, 236 insertions(+), 220 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Telemetry/TelemetryNoticeService.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/TermsAndPolicies/TermsAndPoliciesService.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs index 67bcc302..95739243 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs @@ -177,6 +177,8 @@ internal sealed class FixedSettingsProvider : ISettingsProvider public int ServerWorkerThreads => 0; public bool EnableAutomaticAnalysis => true; + + public bool EnableTelemetry => true; } internal sealed class FixedGitService : IGitService 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 04b94a83..d1d498d0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/TestImplementations/TestSettingsProvider.cs @@ -19,5 +19,7 @@ internal class TestSettingsProvider : ISettingsProvider public int ServerWorkerThreads => Mock.Object.ServerWorkerThreads; public bool EnableAutomaticAnalysis => true; + + public bool EnableTelemetry => true; } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs index 43a434b7..b15af738 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/TestHarnessServices.cs @@ -92,4 +92,6 @@ public sealed class TestSettingsProvider : ISettingsProvider public int ServerWorkerThreads => 0; public bool EnableAutomaticAnalysis => true; + + public bool EnableTelemetry => true; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs index ae9e2dec..cf7fab6b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/TelemetryManagerTests.cs @@ -41,6 +41,25 @@ public void Setup() 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() { 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 11d37930..2b809e64 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Telemetry/TelemetryManager.cs @@ -23,18 +23,21 @@ public class TelemetryManager : ITelemetryManager private readonly IIdeServerClient _client; private readonly IDeviceIdStore _deviceIdStore; private readonly IExtensionMetadataProvider _extensionMetadataProvider; + private readonly ISettingsProvider _settingsProvider; [ImportingConstructor] public TelemetryManager( ILogger logger, IIdeServerClient client, IDeviceIdStore deviceIdStore, - IExtensionMetadataProvider extensionMetadataProvider) + IExtensionMetadataProvider extensionMetadataProvider, + [Import(AllowDefault = true)] ISettingsProvider settingsProvider = null) { _logger = logger; _client = client; _deviceIdStore = deviceIdStore; _extensionMetadataProvider = extensionMetadataProvider; + _settingsProvider = settingsProvider; } /// @@ -48,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; } @@ -81,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; } @@ -111,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/Interfaces/Extension/ISettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs index 222e9522..6b07c49d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Extension/ISettingsProvider.cs @@ -11,5 +11,7 @@ public interface ISettingsProvider int ServerWorkerThreads { get; } bool EnableAutomaticAnalysis { get; } + + bool EnableTelemetry { get; } } } 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 73e20355..996dc43a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -86,7 +86,7 @@ - + diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs index a56910ee..62205560 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs @@ -17,7 +17,6 @@ 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; @@ -51,9 +50,6 @@ public class EditorDocumentLifecycleHandler : IWpfTextViewCreationListener [Import] private readonly IErrorListWindowHandler _errorListWindowHandler; - [Import] - private readonly TermsAndPoliciesService _termsAndPoliciesService; - [Import] private readonly AceStaleChecker _aceStaleChecker; @@ -280,14 +276,6 @@ private async Task ReviewContentAsync(string path, ITextBuffer buffer) { try { - var termsAccepted = await _termsAndPoliciesService.EvaluateTermsAndPoliciesAcceptanceAsync(); - - if (!termsAccepted) - { - _logger.Warn("Skipping CodeScene analysis, Terms & Policies have not been accepted.", true); - return; - } - var code = buffer.CurrentSnapshot.GetText(); var (result, _) = await _reviewer.ReviewWithDeltaAsync(path, code); await ApplyReviewResultsAsync(result, buffer); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs index f62a3d67..2a92e46a 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/General.cs @@ -71,6 +71,11 @@ public General() [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.")] diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs index f6ec0488..38b58fef 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Options/GeneralSettingsProvider.cs @@ -16,5 +16,7 @@ public class GeneralSettingsProvider : ISettingsProvider 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/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 928d9cb8..8cd3fe63 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -12,6 +12,7 @@ using Codescene.VSExtension.Core.Util; using Codescene.VSExtension.VS2022.Application.ErrorHandling; 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; @@ -73,9 +74,9 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Commands await this.RegisterCommandsAsync(); - // Cli file await CheckCliFileAsync(); JoinableTaskFactory.RunAsync(() => StartIdeServerAsync(cancellationToken)).FileAndForget("VS2022Package/StartIdeServer"); + JoinableTaskFactory.RunAsync(ShowTelemetryNoticeAsync).FileAndForget("VS2022Package/TelemetryNotice"); // Subscribe on active document change event await SubscribeOnActiveWindowChangeAsync(); @@ -263,6 +264,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(); From d78a7110aca97bc5af21c23e8032044677b443bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 2 Oct 2026 18:28:35 +0200 Subject: [PATCH 12/38] feat: drop start and stop automatic analysis from the extension menu --- .../Commands/AutomaticAnalysisCommands.cs | 34 ------------------- .../VSCommandTable.cs | 2 -- .../VSCommandTable.vsct | 14 -------- 3 files changed, 50 deletions(-) delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs deleted file mode 100644 index 18de99bf..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Commands/AutomaticAnalysisCommands.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System.Threading.Tasks; -using Codescene.VSExtension.VS2022.Options; -using Community.VisualStudio.Toolkit; -using Microsoft.VisualStudio.Shell; - -namespace Codescene.VSExtension.VS2022.Commands -{ - [Command(PackageGuids.CodeSceneCmdSetString, PackageIds.StopAutomaticAnalysis)] - internal sealed class StopAutomaticAnalysisCommand : BaseCommand - { - protected override Task ExecuteAsync(OleMenuCmdEventArgs e) - { - return SetAutomaticAnalysisAsync(false); - } - - internal static async Task SetAutomaticAnalysisAsync(bool enabled) - { - var general = General.Instance; - general.EnableAutomaticAnalysis = enabled; - await general.SaveAsync(); - } - } - - [Command(PackageGuids.CodeSceneCmdSetString, PackageIds.StartAutomaticAnalysis)] - internal sealed class StartAutomaticAnalysisCommand : BaseCommand - { - protected override Task ExecuteAsync(OleMenuCmdEventArgs e) - { - return StopAutomaticAnalysisCommand.SetAutomaticAnalysisAsync(true); - } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs index c670f882..dd0d65cf 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.cs @@ -33,8 +33,6 @@ internal sealed partial class PackageIds public const int CodeSceneCommandsGroup = 0x1022; public const int OpenSettings = 0x1025; public const int CopyDeviceId = 0x1026; - public const int StopAutomaticAnalysis = 0x1028; - public const int StartAutomaticAnalysis = 0x1029; //public const int RevokeTerms = 0x1027; public const int SignInCommand = 0x0400; public const int OpenStatusWindowCommand = 0x0800; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct index 4fd2a33e..4f38a6c9 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VSCommandTable.vsct @@ -71,18 +71,6 @@ Settings - - private async Task ReviewContentAsync(string path, ITextBuffer buffer) { + if (!HasOpenViews(buffer)) + { + return; + } + try { var code = buffer.CurrentSnapshot.GetText(); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs index 4dcd2280..bc80bae4 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs @@ -127,6 +127,7 @@ public int OnBeforeCloseSolution(object pUnkReserved) _solutionInitializationTask = null; _initializedWorkspacePath = null; _initGeneration++; + _reviewPipeline?.SuspendBufferReviews(); } _branchWatcher?.Dispose(); @@ -140,14 +141,7 @@ public int OnBeforeCloseSolution(object pUnkReserved) public int OnAfterOpenProject(IVsHierarchy pHierarchy, int fAdded) { - if (_workspaceWatch == null) - { - ScheduleWorkspaceInitialization(null); - return VSConstants.S_OK; - } - - _scheduler.Schedule(ct => RefreshWorkspacePathsAsync()); - return VSConstants.S_OK; + return ScheduleWorkspaceRefresh(); } public int OnQueryCloseProject(IVsHierarchy pHierarchy, int fRemoving, ref int pfCancel) => VSConstants.S_OK; @@ -158,7 +152,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; @@ -189,6 +186,7 @@ void IVsSolutionEvents7.OnAfterCloseFolder(string folderPath) void IVsSolutionEvents7.OnAfterLoadAllDeferredProjects() { + ScheduleWorkspaceRefresh(); } public void Dispose() @@ -219,6 +217,18 @@ private void Log(Func logAction) }); } + private int ScheduleWorkspaceRefresh() + { + 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)); @@ -315,6 +325,7 @@ private async Task RunSolutionInitializationAsync(string solutionPath, bool fold return; } + ResumeBufferReviewsIfCurrent(generation); ResetWorkspaceForInitialization(); ClearAnalysisCaches(); _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); @@ -422,11 +433,12 @@ private async Task RefreshWorkspacePathsAsync() return; } - var solution = await VS.Solutions.GetCurrentSolutionAsync(); - var solutionPath = solution?.FullPath; - if (string.IsNullOrEmpty(solutionPath)) + string solutionPath; + int generation; + lock (_initGate) { solutionPath = _initializedWorkspacePath; + generation = _initGeneration; } if (string.IsNullOrEmpty(solutionPath)) @@ -434,22 +446,53 @@ private async Task RefreshWorkspacePathsAsync() return; } + var watchSession = _workspaceWatch.WatchSession; await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - IReadOnlyCollection workspacePaths; - if (Directory.Exists(solutionPath)) + var workspacePaths = CurrentProjectDirectories(solutionPath, generation, watchSession); + if (workspacePaths == null) { - workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(null, solutionPath); + return; } - else if (_solution != null) + + await _workspaceWatch.SyncAsync(new List(workspacePaths), watchSession); + } + + private IReadOnlyCollection CurrentProjectDirectories(string solutionPath, int generation, long watchSession) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (!RefreshStillCurrent(generation, watchSession)) { - workspacePaths = SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); + return null; } - else + + var workspacePaths = ProjectDirectories(solutionPath); + if (workspacePaths == null || !RefreshStillCurrent(generation, watchSession)) { - return; + 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) + { + return null; } - await _workspaceWatch.SyncAsync(workspacePaths == null ? Array.Empty() : new List(workspacePaths)); + return SolutionProjectDiscovery.GetProjectDirectories(_solution, solutionPath); } private async Task OnBranchChangedAsync(string newBranch) @@ -507,6 +550,19 @@ private bool IsCurrentInitialization(int generation) } } + private void ResumeBufferReviewsIfCurrent(int generation) + { + lock (_initGate) + { + if (generation != _initGeneration) + { + return; + } + + _reviewPipeline?.ResumeBufferReviews(); + } + } + private void ResetWorkspaceForInitialization() { _branchWatcher?.Dispose(); From 2a672215de9433a84461e4d8cf078e29e64110ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 2 Oct 2026 23:24:42 +0200 Subject: [PATCH 14/38] chore: pin the published IDE-1.0.70 cs-ide binary The Windows build for 6f48030998bf5566ec7ae4e430d2cf9b43d06744 is available, so the extension can verify that executable. --- .../CliSettingsProviderTests.cs | 2 +- .../Application/Cli/CliSettingsProvider.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs index 9570b948..11337e3c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliSettingsProviderTests.cs @@ -9,7 +9,7 @@ namespace Codescene.VSExtension.Core.Tests public class CliSettingsProviderTests { private readonly string expectedVersion = "6f48030998bf5566ec7ae4e430d2cf9b43d06744"; - private readonly string expectedCliBinarySha256 = "eff7759f0c23b54938df6bbf4ce0a11bd1690097ad9b167fe8df602dcb3d820e"; + private readonly string expectedCliBinarySha256 = "4319a985dd0b2741d8a96e50446988cb63c0242827d3e1ff472097faab122968"; [TestMethod] public void RequiredDevToolVersion_ShouldReturnExpectedValue() diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs index 10f78ce9..9f87d237 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliSettingsProvider.cs @@ -15,7 +15,7 @@ public class CliSettingsProvider : ICliSettingsProvider // used by the build pipeline to bundle the CLI with the extension public string RequiredDevToolVersion => "6f48030998bf5566ec7ae4e430d2cf9b43d06744"; - public string RequiredCliBinarySha256 => "eff7759f0c23b54938df6bbf4ce0a11bd1690097ad9b167fe8df602dcb3d820e"; + public string RequiredCliBinarySha256 => "4319a985dd0b2741d8a96e50446988cb63c0242827d3e1ff472097faab122968"; public string CliArtifactName => $"cs-ide-windows-amd64-{RequiredDevToolVersion}.zip"; From e81edeeabd0b18f859b858e478c9afb65b6ec047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Tue, 6 Oct 2026 08:29:28 +0200 Subject: [PATCH 15/38] feat: drive the monitor from CLI review progress The CLI reports remaining and finished reviews on cs-ide/reviewProgress, so the monitor no longer infers the batch total from a queue on each review result. --- .../CodeHealthMonitorNotifierTests.cs | 62 +++++++++ .../IdeServerClientTests.cs | 33 +++-- .../PersistentCliSyncTests.cs | 18 --- .../ReviewQueueProgressTests.cs | 126 ------------------ .../Cli/IdeServerClient.Notifications.cs | 56 +++++--- .../Application/Cli/IdeServerClient.cs | 3 + .../Services/AnalysisBatchTracker.cs | 37 ----- .../Services/CodeHealthMonitorNotifier.cs | 22 +-- .../Services/ReviewQueueProgress.cs | 111 --------------- .../Models/Cli/Rpc/DeltaNotification.cs | 2 - .../Cli/Rpc/ReviewFailedNotification.cs | 2 - .../Models/Cli/Rpc/ReviewNotification.cs | 2 - .../Models/Cli/Rpc/ReviewQueue.cs | 2 + .../Cli/CachingCodeReviewerProvider.cs | 7 +- 14 files changed, 137 insertions(+), 346 deletions(-) delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs index 9ddabcce..ab8b5baa 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs @@ -1,6 +1,8 @@ // 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 @@ -13,6 +15,7 @@ public class CodeHealthMonitorNotifierTests [TestInitialize] public void Setup() { + DeltaJobTracker.Clear(); _notifier = new CodeHealthMonitorNotifier(); } @@ -88,5 +91,64 @@ 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 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); + } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index 470683a2..96557f75 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -193,14 +193,14 @@ public async Task StopWatchFiles_BeforeAndAfterStart_DoesNotThrow() } [TestMethod] - public async Task DeltaReview_RaisesDeltaAndQueueNotifications() + public async Task DeltaReview_RaisesDeltaNotification() { using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); await harness.Client.StartAsync(); var deltaReceived = new TaskCompletionSource(); - var queueReceived = new TaskCompletionSource(); + var queueCount = 0; harness.Client.DeltaReceived += (_, delta) => deltaReceived.TrySetResult(delta); - harness.Client.QueueChanged += (_, queue) => queueReceived.TrySetResult(queue); + harness.Client.QueueChanged += (_, __) => queueCount++; await harness.ServerRpc.NotifyWithParameterObjectAsync("cs-ide/deltaReview", new { @@ -215,15 +215,14 @@ public async Task DeltaReview_RaisesDeltaAndQueueNotifications() oldGitBlobSha = "old-sha", newGitBlobSha = "new-sha", }, - queue = new { count = 1, files = new[] { "src/b.cs" } }, }); var delta = await WaitForAsync(deltaReceived.Task); - var queue = await WaitForAsync(queueReceived.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((1, Path.Combine("C:/repo", "src", "b.cs")), (queue.Count, queue.Files[0])); + Assert.AreEqual(0, queueCount); } [TestMethod] @@ -284,8 +283,9 @@ public async Task ReviewFiles_EchoesIdAndQueue() var review = await WaitForAsync(reviewReceived.Task); var queue = await WaitForAsync(queueReceived.Task); Assert.AreEqual( - ("req-1", "src/a.ts", GitBlobSha.FromUtf8("hello"), 2), - (review.Id, review.Path, review.Result.GitBlobSha, queue.Count)); + ("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] @@ -510,6 +510,12 @@ public async Task ReviewFiles(JObject parameters) 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, @@ -521,6 +527,12 @@ public async Task ReviewFiles(JObject parameters) continue; } + await _rpc.NotifyWithParameterObjectAsync("cs-ide/reviewProgress", new + { + count = 2, + done = 1, + files = new[] { "b.ts", "c.ts" }, + }); var payload = new JObject { ["path"] = relPath, @@ -531,11 +543,6 @@ public async Task ReviewFiles(JObject parameters) ["score"] = 9.68, ["gitBlobSha"] = GitBlobSha.FromUtf8(content), }, - ["queue"] = new JObject - { - ["count"] = 2, - ["files"] = new JArray("b.ts", "c.ts"), - }, }; if (!string.IsNullOrEmpty(id)) { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs index eb65319e..e5f04e71 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/PersistentCliSyncTests.cs @@ -1,6 +1,5 @@ // Copyright (c) CodeScene. All rights reserved. -using Codescene.VSExtension.Core.Application.Services; using Codescene.VSExtension.Core.Models.Cli.Delta; using Codescene.VSExtension.Core.Util; @@ -24,23 +23,6 @@ public void LogFields_CapsLongListsAndSkipsNulls() Assert.AreEqual("1, 2 omitted=1", capped); } - [TestMethod] - public void AnalysisBatchTracker_GrowsWhenRemainingIncreasesThenResets() - { - var tracker = new AnalysisBatchTracker(); - - var first = tracker.Update(2); - var second = tracker.Update(3); - var idle = tracker.Update(0); - - Assert.AreEqual(2, first.TotalCount); - Assert.AreEqual(2, first.RemainingCount); - Assert.AreEqual(3, second.TotalCount); - Assert.AreEqual(3, second.RemainingCount); - Assert.AreEqual(0, idle.TotalCount); - Assert.AreEqual(0, idle.RemainingCount); - } - [TestMethod] public void DeltaScoreText_ShowsCurrentScoreWhenOldScoreIsMissing() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs deleted file mode 100644 index 86fc1e3d..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewQueueProgressTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -// 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] - public class ReviewQueueProgressTests - { - [TestMethod] - public void Update_ZeroCount_FlushesImmediately() - { - var snapshots = new List(); - using (var progress = new ReviewQueueProgress(snapshots.Add)) - { - progress.Update(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); - progress.Update(new ReviewQueue { Count = 0, Files = Array.Empty() }); - } - - Assert.IsTrue(snapshots.Exists(queue => queue.Count == 0)); - } - - [TestMethod] - public void Update_SubsequentPositiveCount_FlushesLatestSnapshot() - { - var flushed = new ManualResetEventSlim(); - var snapshots = new List(); - using (var progress = new ReviewQueueProgress(snapshot => - { - snapshots.Add(snapshot); - flushed.Set(); - })) - { - progress.Update(new ReviewQueue { Count = 1, Files = new[] { "a.cs" } }); - Assert.IsTrue(flushed.Wait(TimeSpan.FromSeconds(5))); - flushed.Reset(); - - progress.Update(new ReviewQueue { Count = 2, Files = new[] { "a.cs", "b.cs" } }); - - Assert.IsTrue(flushed.Wait(TimeSpan.FromSeconds(5))); - } - - Assert.AreEqual(2, snapshots[snapshots.Count - 1].Count); - } - - [TestMethod] - public async Task Update_WhileBusy_EmitsAgainBeforeTheBurstEnds() - { - var emissions = 0; - var second = new TaskCompletionSource(); - using (var progress = new ReviewQueueProgress(queue => - { - emissions++; - if (emissions == 2) - { - second.TrySetResult(queue.Count); - } - })) - { - progress.Update(new ReviewQueue { Count = 10, Files = new[] { "a.cs" } }); - var next = 9; - var started = DateTime.UtcNow; - while (!second.Task.IsCompleted && (DateTime.UtcNow - started).TotalMilliseconds < 2000) - { - progress.Update(new ReviewQueue { Count = next, Files = new[] { "a.cs" } }); - next = Math.Max(1, next - 1); - await Task.Delay(20); - } - - Assert.IsTrue(second.Task.IsCompleted); - } - } - - [TestMethod] - public void Update_AfterDispose_IsIgnored() - { - var changes = 0; - var progress = new ReviewQueueProgress(_ => changes++); - progress.Dispose(); - - progress.Update(new ReviewQueue { Count = 1 }); - - Assert.AreEqual(0, changes); - } - - [TestMethod] - public void ApplyQueue_MapsRunningAndQueuedJobs() - { - DeltaJobTracker.Clear(); - var notifier = new CodeHealthMonitorNotifier(); - notifier.ApplyQueue(new ReviewQueue - { - Count = 2, - 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); - DeltaJobTracker.Clear(); - } - - [TestMethod] - public void Clear_RemovesJobsAndRequestsViewUpdate() - { - DeltaJobTracker.Clear(); - var notifier = new CodeHealthMonitorNotifier(); - 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); - } - } -} 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 index ffae6e6b..ff0d6581 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -51,8 +50,6 @@ private void HandleReview(JObject notification) } var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); var result = normalized["result"]?.ToObject(Serializer); _logger.Info("[cs-ide] received fileReview " + LogFields.Format( LogFields.Field("id", identity.Item1 ?? "(none)"), @@ -64,7 +61,6 @@ private void HandleReview(JObject notification) Path = identity.Item2, RepoRoot = identity.Item3, Result = result, - Queue = queue, }); } @@ -77,8 +73,6 @@ private void HandleDelta(JObject notification) } var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); var resultToken = normalized["result"]; var delta = resultToken == null || resultToken.Type == JTokenType.Null ? null @@ -93,7 +87,6 @@ private void HandleDelta(JObject notification) Path = identity.Item2, RepoRoot = identity.Item3, Result = delta, - Queue = queue, }); } @@ -106,18 +99,30 @@ private void HandleReviewFailure(JObject notification) } var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var queue = ParseQueue(normalized, identity.Item3); - EmitQueue(queue); ReviewFailed?.Invoke(this, new ReviewFailedNotification { Id = identity.Item1, Path = identity.Item2, RepoRoot = identity.Item3, Message = (string)normalized["message"], - Queue = queue, }); } + 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); @@ -172,34 +177,43 @@ private WatchInventory ParseWatchInventory(JObject notification, string fallback }; } - private ReviewQueue ParseQueue(JObject notification, string repoRoot) + private ReviewQueue ParseReviewProgress(JObject notification) { - var queueToken = notification?["queue"] as JObject; - if (queueToken == null) + if (notification == null) { return null; } - var files = queueToken["files"] as JArray; + var files = notification["files"] as JArray; return new ReviewQueue { - Count = queueToken["count"]?.Value() ?? 0, + Count = ReadCount(notification["count"]), + Done = ReadCount(notification["done"]), Files = (files ?? new JArray()) + .Where(token => token != null && token.Type == JTokenType.String) .Select(token => (string)token) - .Where(value => !string.IsNullOrEmpty(value)) - .Select(relPath => Path.Combine(repoRoot ?? string.Empty, relPath.Replace('/', Path.DirectorySeparatorChar))) .ToList(), }; } - private void EmitQueue(ReviewQueue queue) + private int ReadCount(JToken token) { - if (queue == null) + if (token == null) { - return; + return 0; + } + + if (token.Type == JTokenType.Integer) + { + return token.Value(); + } + + if (token.Type == JTokenType.Float) + { + return token.Value(); } - QueueChanged?.Invoke(this, queue); + 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 index dd758cb9..e9825944 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -570,6 +570,9 @@ public RpcNotifications(IdeServerClient client) [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/Services/AnalysisBatchTracker.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs deleted file mode 100644 index b1a85e08..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/AnalysisBatchTracker.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -namespace Codescene.VSExtension.Core.Application.Services -{ - public sealed class AnalysisBatchTracker - { - private int _total; - private int _lastRemaining; - - public (int TotalCount, int RemainingCount) Update(int remaining) - { - if (remaining <= 0) - { - Reset(); - return (0, 0); - } - - if (_total == 0) - { - _total = remaining; - } - else if (remaining > _lastRemaining) - { - _total += remaining - _lastRemaining; - } - - _lastRemaining = remaining; - return (_total, remaining); - } - - public void Reset() - { - _total = 0; - _lastRemaining = 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 aa7326be..ff931d41 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -19,7 +19,6 @@ namespace Codescene.VSExtension.Core.Application.Services public class CodeHealthMonitorNotifier : ICodeHealthMonitorNotifier { private readonly ConcurrentDictionary _activeJobs = new ConcurrentDictionary(); - private readonly AnalysisBatchTracker _batch = new AnalysisBatchTracker(); private readonly object _lock = new object(); private bool _automaticAnalysisEnabled = true; @@ -120,11 +119,20 @@ public void ApplyQueue(ReviewQueue queue) } DeltaJobTracker.Replace(jobs); - var remaining = Math.Max(jobs.Count, queue?.Count ?? 0); - var progress = _batch.Update(remaining); - RemainingCount = progress.RemainingCount; - TotalCount = progress.TotalCount > 0 ? progress.TotalCount : (int?)null; - AnalysisState = RemainingCount > 0 ? "running" : "idle"; + var remaining = queue?.Count ?? 0; + if (remaining <= 0) + { + RemainingCount = 0; + TotalCount = null; + AnalysisState = "idle"; + } + else + { + RemainingCount = remaining; + TotalCount = (queue?.Done ?? 0) + remaining; + AnalysisState = "running"; + } + ViewUpdateRequested?.Invoke(this, EventArgs.Empty); } } @@ -135,7 +143,6 @@ public void Clear() { _activeJobs.Clear(); DeltaJobTracker.Clear(); - _batch.Reset(); if (_automaticAnalysisEnabled) { AnalysisState = "idle"; @@ -156,7 +163,6 @@ private void SetStoppedUnlocked() { _activeJobs.Clear(); DeltaJobTracker.Clear(); - _batch.Reset(); AnalysisState = "stopped"; TotalCount = null; RemainingCount = 0; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs deleted file mode 100644 index ea0d594f..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/ReviewQueueProgress.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using System; -using System.Threading; -using Codescene.VSExtension.Core.Models.Cli.Rpc; - -namespace Codescene.VSExtension.Core.Application.Services -{ - public sealed class ReviewQueueProgress : IDisposable - { - public const int ThrottleMs = 250; - private readonly Action _onChange; - private readonly object _gate = new object(); - private ReviewQueue _latest = new ReviewQueue(); - private Timer _timer; - private bool _windowOpen; - private bool _trailing; - private bool _disposed; - - public ReviewQueueProgress(Action onChange) - { - _onChange = onChange ?? throw new ArgumentNullException(nameof(onChange)); - } - - public void Update(ReviewQueue snapshot) - { - Action emit = null; - lock (_gate) - { - if (_disposed) - { - return; - } - - _latest = snapshot ?? new ReviewQueue(); - if (_latest.Count == 0) - { - CloseWindow(); - var cleared = _latest; - emit = () => _onChange(cleared); - } - else if (!_windowOpen) - { - _windowOpen = true; - _trailing = false; - var leading = _latest; - Arm(ThrottleMs); - emit = () => _onChange(leading); - } - else - { - _trailing = true; - } - } - - emit?.Invoke(); - } - - public void Dispose() - { - lock (_gate) - { - _disposed = true; - _windowOpen = false; - _trailing = false; - _timer?.Dispose(); - _timer = null; - } - } - - private void Flush() - { - ReviewQueue snapshot = null; - lock (_gate) - { - if (_disposed || !_trailing) - { - _windowOpen = false; - return; - } - - _trailing = false; - _windowOpen = false; - snapshot = _latest; - } - - if (snapshot != null) - { - _onChange(snapshot); - } - } - - private void CloseWindow() - { - _windowOpen = false; - _trailing = false; - _timer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - private void Arm(int dueMs) - { - if (_timer == null) - { - _timer = new Timer(_ => Flush(), null, dueMs, Timeout.Infinite); - return; - } - - _timer.Change(dueMs, Timeout.Infinite); - } - } -} 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 index 979f78a0..28973206 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs @@ -13,7 +13,5 @@ public class DeltaNotification public string RepoRoot { get; set; } public DeltaResponseModel Result { get; set; } - - public ReviewQueue Queue { 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 index 24cfaa79..12c1870f 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewFailedNotification.cs @@ -11,7 +11,5 @@ public class ReviewFailedNotification public string RepoRoot { get; set; } public string Message { get; set; } - - public ReviewQueue Queue { 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 index e9478f8b..934b7b08 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs @@ -13,7 +13,5 @@ public class ReviewNotification public string RepoRoot { get; set; } public CliReviewModel Result { get; set; } - - public ReviewQueue Queue { get; set; } } } 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 index 1dd75b96..6d601978 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewQueue.cs @@ -8,6 +8,8 @@ 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.VS2022/Application/Cli/CachingCodeReviewerProvider.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs index aa16de0d..2dac0cfc 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -25,7 +25,6 @@ public class CachingCodeReviewerProvider : ICodeReviewer private readonly ICodeReviewer _inner; private readonly CodeHealthMonitorNotifier _notifier; private readonly IAsyncTaskScheduler _scheduler; - private readonly ReviewQueueProgress _queueProgress; [ImportingConstructor] public CachingCodeReviewerProvider( @@ -44,11 +43,7 @@ public CachingCodeReviewerProvider( _scheduler = scheduler; _notifier = notifier; _notifier.ViewUpdateRequested += OnViewUpdateRequested; - _queueProgress = new ReviewQueueProgress(queue => - { - _notifier.ApplyQueue(queue); - }); - client.QueueChanged += (_, queue) => _queueProgress.Update(queue); + client.QueueChanged += (_, queue) => _notifier.ApplyQueue(queue); _notifier.SetAutomaticAnalysisEnabled(settings?.EnableAutomaticAnalysis ?? true); _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, preflightManager, pipeline, settings); } From e58b8ce55e40fe6db16d3cccd96ccd03edd86e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 09:06:35 +0200 Subject: [PATCH 16/38] chore: bump NuGet dependencies and require VS 2022 17.14+ - Raise minimum Visual Studio to 17.14 (17.12 LTSC is out of support) and align VS-provided packages to it: VS SDK 17.14.40265, StreamJsonRpc 2.22.11, Newtonsoft.Json 13.0.4 - Pin MessagePack to 2.5.301 to match the 2.5.x assembly VS ships while keeping NuGet audit clean - Remove unused packages: Markdig, Newtonsoft.Json.Schema, Microsoft.Extensions.DependencyInjection.Abstractions, Community.VisualStudio.Toolkit.DependencyInjection.*, and direct System.Memory/System.Threading.Tasks.Extensions - Bump Toolkit, VSCT, VSSDK.BuildTools, LibGit2Sharp, WebView2 and test packages to latest - Suppress VS platform assemblies newly pulled in by SDK 17.14 from the VSIX - Use AsyncManualResetEvent for package initialization (VSTHRD003 with Threading analyzers 17.14) --- ...e.VSExtension.Core.IntegrationTests.csproj | 10 +++--- ....VSExtension.Core.SubcutaneousTests.csproj | 6 ++-- .../Codescene.VSExtension.Core.Tests.csproj | 10 +++--- .../Codescene.VSExtension.Core.csproj | 13 +++----- .../Codescene.VSExtension.VS2022.csproj | 33 ++++++++----------- .../WebComponent/CodeSceneToolWindow.cs | 2 +- .../VS2022Package.cs | 6 ++-- .../source.extension.vsixmanifest | 4 +-- 8 files changed, 37 insertions(+), 47 deletions(-) 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 46293e23..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 @@ -16,15 +16,15 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers - + 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 3545230b..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 @@ -16,9 +16,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers 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 7225bc89..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 @@ -10,10 +10,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers 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 0cd8fca3..45c78941 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj @@ -15,20 +15,17 @@ - - - - - - + + + + + analyzers all runtime; build; native; contentfiles; analyzers - - 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 0d2f0a55..ea0b05ae 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -178,6 +178,11 @@ source.extension.cs + + + + + Menus.ctmenu @@ -200,40 +205,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 - - - 3.0.14 + 1.0.4258.31 all 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 e6c68036..f49870ae 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/ToolWindows/WebComponent/CodeSceneToolWindow.cs @@ -139,7 +139,7 @@ private static void ScheduleShowAfterInitialization() { try { - await package.Initialized; + await package.WaitForInitializationAsync(); await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.PackageDisposalToken); if (_userControl == null) { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 8cd3fe63..2315478c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -40,7 +40,7 @@ namespace Codescene.VSExtension.VS2022; [ProvideToolWindow(typeof(CodeSceneToolWindow.Pane), Style = VsDockStyle.Tabbed, Window = WindowGuids.SolutionExplorer)] public sealed class VS2022Package : ToolkitPackage { - private readonly TaskCompletionSource _initialized = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly AsyncManualResetEvent _initialized = new AsyncManualResetEvent(); private ILogger _logger; private SolutionEventsHandler _solutionEventsHandler; private IAsyncTaskScheduler _scheduler; @@ -50,7 +50,7 @@ public sealed class VS2022Package : ToolkitPackage public CancellationToken PackageDisposalToken => DisposalToken; - internal Task Initialized => _initialized.Task; + internal Task WaitForInitializationAsync() => _initialized.WaitAsync(); protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { @@ -109,7 +109,7 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke } finally { - _initialized.TrySetResult(null); + _initialized.Set(); } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest index ec69f854..07050456 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest @@ -9,12 +9,12 @@ ai;ai code review;code analysis;code health;code metrics;code review;code quality;technical debt;cyclomatic complexity;refactoring - + amd64 - + From f3d605a6e5ea2f91dd87cb595d50e7a1932e03b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 11:59:42 +0200 Subject: [PATCH 17/38] fix(cli): align persistent CLI behavior with recent VS Code fixes Map HTTP statuses from cs-ide RPC errors, restore the monitor when a buffer matches disk again, and match CLI repo roots across canonical Windows paths. --- .../CodeReviewerPipelineRestoreTests.cs | 109 ++++++++++++ .../IdeServerClientTests.cs | 42 ++++- .../ReviewPipelineCanonicalRootTests.cs | 125 ++++++++++++++ .../ReviewPipelinePresentationEvents.cs | 55 +++++++ .../ReviewPipelineTests.cs | 51 +----- .../RpcPathTests.cs | 77 +++++++++ .../Application/Cli/CodeReviewer.cs | 5 + .../Application/Cli/IdeServerClient.cs | 10 +- .../Cli/ReviewPipeline.Notifications.cs | 2 +- .../Application/Cli/ReviewPipeline.cs | 3 +- .../Git/WorkspaceWatchCoordinator.cs | 16 +- .../Util/RpcPath.cs | 155 +++++++++++++++++- 12 files changed, 593 insertions(+), 57 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineCanonicalRootTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelinePresentationEvents.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs 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..2c04dfda --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs @@ -0,0 +1,109 @@ +// 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 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; + private Mock _mockTelemetryManager; + private Mock _mockGitService; + + [TestInitialize] + public void Setup() + { + _mockLogger = new Mock(); + _mockMapper = new Mock(); + _mockExecutor = new Mock(); + _mockTelemetryManager = new Mock(); + _mockGitService = 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, + _mockTelemetryManager.Object, + _mockGitService.Object, + pipeline: 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, + _mockTelemetryManager.Object, + _mockGitService.Object, + pipeline: 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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index 96557f75..cabceb59 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -3,6 +3,7 @@ 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; @@ -133,6 +134,35 @@ public async Task DeviceIdAsync_ReadsCamelCaseOrKebabCase() Assert.AreEqual("device-42", deviceId); } + [TestMethod] + public async Task ReviewAsync_HttpFailureMessage_UsesHttpStatusInDevtoolsException() + { + using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); + await harness.Client.StartAsync(); + + var exception = await Assert.ThrowsAsync(() => harness.Client.ReviewAsync(new ReviewRequestModel + { + FilePath = "src/a.cs", + FileContent = "trigger-http-401", + })); + + Assert.AreEqual(401, exception.Status); + Assert.Contains("Request failed [401]", exception.Message); + } + + [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 RefactoringRpcEndpoints_NormalizeResponses() { @@ -435,6 +465,11 @@ public FixtureServer(JsonRpc rpc) [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""}"); } @@ -451,8 +486,13 @@ public JArray FnsToRefactor(JObject request) } [JsonRpcMethod("cs-ide/refactor", UseSingleObjectParameterDeserialization = true)] - public JObject Refactor(JObject request) + 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""}"); } 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/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 index c8013fba..25986b70 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -14,7 +14,7 @@ public class ReviewPipelineTests { private const string RepoRoot = "/repo"; private Mock _client; - private PresentationEvents _events; + private ReviewPipelinePresentationEvents _events; private List<(string RepoRoot, IReadOnlyList Files)> _batches; private int _nextId; private ReviewPipeline _pipeline; @@ -23,7 +23,7 @@ public class ReviewPipelineTests public void Setup() { _client = new Mock(); - _events = new PresentationEvents(); + _events = new ReviewPipelinePresentationEvents(); _batches = new List<(string, IReadOnlyList)>(); _nextId = 1; _client.Setup(x => x.ReviewFiles(It.IsAny(), It.IsAny>())) @@ -659,52 +659,5 @@ private async Task WaitUntil(Func predicate) throw new TimeoutException("timed out waiting for pipeline presentation"); } - - private sealed class PresentationEvents : 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/RpcPathTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs new file mode 100644 index 00000000..897ace11 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs @@ -0,0 +1,77 @@ +// 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 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 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 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/Application/Cli/CodeReviewer.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs index ab8de41b..d9468703 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CodeReviewer.cs @@ -165,6 +165,11 @@ public async Task GetOrComputeBaselineRawScoreAsync(string path, string UpdateMonitor = ShouldUpdateMonitor(path, content), }); + if (!BufferDiffersFromDisk(path, content)) + { + _pipeline.RestoreFromDiskIfBufferOwned(repoRoot, document); + } + if (cliReview == null) { return (null, null); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs index e9825944..f4e10026 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -6,6 +6,7 @@ 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; @@ -397,7 +398,14 @@ private async Task InvokeRequestAsync(string method, object argument, Canc } catch (RemoteInvocationException ex) { - throw new DevtoolsException(ex.Message, ex.ErrorCode, null); + 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); } } 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 index 9ffa5bd1..084e8674 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -286,7 +286,7 @@ private void RemoveWatchReview(string pathKey) private async Task WatchPresentationAsync(string repoRoot, string relPath, string receivedSha) { var posixPath = RpcPath.ToPosixRelPath(relPath); - var filePath = RpcPath.CombineRepo(repoRoot, posixPath); + var filePath = RpcPath.CombineRepo(RpcPath.ToLocalRepoRoot(repoRoot), posixPath); var document = _fileAccess.FindOpenDocument(filePath); if (document == null) { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs index 3bf3d042..df9fddeb 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -248,7 +248,7 @@ private static bool MatchesHash(string received, string expected) private static string PathKey(string repoRoot, string relPath) { - return RpcPath.NormalizeFsPath(repoRoot) + "\0" + RpcPath.ToPosixRelPath(relPath); + return RpcPath.CanonicalRepoRoot(repoRoot) + "\0" + RpcPath.ToPosixRelPath(relPath); } private static string PathKeyForDocument(string repoRoot, ReviewDocument document) @@ -290,6 +290,7 @@ private bool AutomaticAnalysisEnabled() 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]; diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs index 68922af9..2f3b8e41 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -266,6 +266,7 @@ private bool ReplaceWatchedRoots(IReadOnlyList targets, long? watchSessi _knownRoots.Clear(); foreach (var target in targets) { + RpcPath.RememberLocalRepoRoot(target); _knownRoots[RpcPath.NormalizeFsPath(target)] = target; } @@ -521,9 +522,18 @@ private void PublishActiveReposUnlocked() private string ResolveRepoRoot(string reported) { - return _knownRoots.TryGetValue(RpcPath.NormalizeFsPath(reported), out var known) - ? known - : Path.GetFullPath(reported ?? string.Empty); + 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/Util/RpcPath.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs index b72551d4..12f02373 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/RpcPath.cs @@ -1,13 +1,28 @@ // 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)) @@ -26,6 +41,51 @@ public static string NormalizeFsPath(string filePath) } } + 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('\\', '/'); @@ -38,7 +98,12 @@ public static string RelativePosix(string repoRoot, string absolutePath) public static bool PathsEqual(string left, string right) { - return string.Equals(NormalizeFsPath(left), NormalizeFsPath(right), StringComparison.Ordinal); + 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) @@ -46,5 +111,93 @@ 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); } } From 26dfa6e1732663830bb0ff5f0d0949727cde1d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 14:02:57 +0200 Subject: [PATCH 18/38] fix(monitor): refresh view on inventory prune and restore monitor telemetry Pruning the delta cache after watchInventoryChanged now requests a monitor view update, so the monitor clears when switching to a branch without changes. Restore code_health_monitor file_added/file_updated/file_removed telemetry in the review pipeline and send file_removed for pruned entries. --- .../DeltaTelemetryHelperTests.cs | 17 ++++++++ .../Util/DeltaTelemetryHelper.cs | 5 +-- .../VisualStudioReviewPipelinePresentation.cs | 17 +++++++- .../VisualStudioWorkspaceWatchHost.cs | 41 ++++++++++++++++++- 4 files changed, 73 insertions(+), 7 deletions(-) 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/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.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs index 6089e22f..9579d2cf 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs @@ -7,9 +7,12 @@ 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; @@ -24,6 +27,7 @@ public sealed class VisualStudioReviewPipelinePresentation : IReviewPipelinePres private readonly ICodeHealthMonitorNotifier _notifier; private readonly IAsyncTaskScheduler _scheduler; private readonly CodeSceneMarginSettingsManager _marginSettings; + private readonly ITelemetryManager _telemetryManager; [ImportingConstructor] public VisualStudioReviewPipelinePresentation( @@ -31,13 +35,15 @@ public VisualStudioReviewPipelinePresentation( IErrorListWindowHandler errorListWindowHandler, ICodeHealthMonitorNotifier notifier, IAsyncTaskScheduler scheduler, - CodeSceneMarginSettingsManager marginSettings) + CodeSceneMarginSettingsManager marginSettings, + ITelemetryManager telemetryManager) { _mapper = mapper; _errorListWindowHandler = errorListWindowHandler; _notifier = notifier; _scheduler = scheduler; _marginSettings = marginSettings; + _telemetryManager = telemetryManager; } public void ReviewStarted(ReviewDocument document) @@ -90,7 +96,14 @@ public void PresentDelta(PresentedDelta delta) return; } - new DeltaCacheService().SetPresentationDelta(delta.Document.FilePath, delta.Result); + 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(); } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs index 20bf50b3..b67033a0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioWorkspaceWatchHost.cs @@ -3,9 +3,13 @@ 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; @@ -17,11 +21,18 @@ namespace Codescene.VSExtension.VS2022.Application.Adapters public sealed class VisualStudioWorkspaceWatchHost : IWorkspaceWatchHost { private readonly IOpenDocumentContentProvider _contentProvider; + private readonly ICodeHealthMonitorNotifier _notifier; + private readonly ITelemetryManager _telemetryManager; [ImportingConstructor] - public VisualStudioWorkspaceWatchHost(IOpenDocumentContentProvider contentProvider) + public VisualStudioWorkspaceWatchHost( + IOpenDocumentContentProvider contentProvider, + ICodeHealthMonitorNotifier notifier, + ITelemetryManager telemetryManager) { _contentProvider = contentProvider; + _notifier = notifier; + _telemetryManager = telemetryManager; } public IReadOnlyList GetDirtyDocuments() @@ -33,7 +44,17 @@ public IReadOnlyList GetDirtyDocuments() public void PruneMonitor(IReadOnlyList repoRoots, ISet keepPaths) { - new DeltaCacheService().RemoveEntriesNotIn(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() @@ -59,6 +80,22 @@ private static bool IsDirtyDocument(EnvDTE.Document document) 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(); From 9fe36651d99e79018dd303fb0e58af67abb7170e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 14:48:39 +0200 Subject: [PATCH 19/38] fix(monitor): wire monitor updates at package startup Queue progress and view refresh were only subscribed when the code reviewer was first created, which requires an open code editor. Starting VS without one left the monitor stale while reviews kept arriving. --- .../Cli/CachingCodeReviewerProvider.cs | 18 +------- .../Services/CodeHealthMonitorBinder.cs | 44 +++++++++++++++++++ .../Codescene.VSExtension.VS2022.csproj | 1 + .../VS2022Package.cs | 2 + 4 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Services/CodeHealthMonitorBinder.cs 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 2dac0cfc..9d950ee6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -1,6 +1,5 @@ // Copyright (c) CodeScene. All rights reserved. -using System; using System.ComponentModel.Composition; using System.Threading; using System.Threading.Tasks; @@ -14,7 +13,6 @@ using Codescene.VSExtension.Core.Interfaces.Telemetry; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; -using Codescene.VSExtension.VS2022.ToolWindows.WebComponent; namespace Codescene.VSExtension.VS2022.Application.Cli { @@ -23,8 +21,6 @@ namespace Codescene.VSExtension.VS2022.Application.Cli public class CachingCodeReviewerProvider : ICodeReviewer { private readonly ICodeReviewer _inner; - private readonly CodeHealthMonitorNotifier _notifier; - private readonly IAsyncTaskScheduler _scheduler; [ImportingConstructor] public CachingCodeReviewerProvider( @@ -33,19 +29,12 @@ public CachingCodeReviewerProvider( ICliExecutor executor, ITelemetryManager telemetryManager, IGitService git, - IAsyncTaskScheduler scheduler, IPreflightManager preflightManager, IReviewPipeline pipeline, - IIdeServerClient client, CodeHealthMonitorNotifier notifier, ISettingsProvider settings) { - _scheduler = scheduler; - _notifier = notifier; - _notifier.ViewUpdateRequested += OnViewUpdateRequested; - client.QueueChanged += (_, queue) => _notifier.ApplyQueue(queue); - _notifier.SetAutomaticAnalysisEnabled(settings?.EnableAutomaticAnalysis ?? true); - _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, _notifier, preflightManager, pipeline, settings); + _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, notifier, preflightManager, pipeline, settings); } public Task ReviewAsync(string path, string content, bool isBaseline = false, long? operationGeneration = null, CancellationToken cancellationToken = default) @@ -72,10 +61,5 @@ public Task GetOrComputeBaselineRawScoreAsync(string path, string baseli { return _inner.GetOrComputeBaselineRawScoreAsync(path, baselineContent, operationGeneration, cancellationToken, baselineCommit); } - - private void OnViewUpdateRequested(object sender, EventArgs e) - { - _scheduler.Schedule(ct => CodeSceneToolWindow.UpdateViewAsync()); - } } } 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 ea0b05ae..335fc195 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -79,6 +79,7 @@ + diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs index 2315478c..d05263d3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/VS2022Package.cs @@ -11,6 +11,7 @@ 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; @@ -75,6 +76,7 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke await this.RegisterCommandsAsync(); await CheckCliFileAsync(); + await GetServiceAsync(); JoinableTaskFactory.RunAsync(() => StartIdeServerAsync(cancellationToken)).FileAndForget("VS2022Package/StartIdeServer"); JoinableTaskFactory.RunAsync(ShowTelemetryNoticeAsync).FileAndForget("VS2022Package/TelemetryNotice"); From d88c3856ac59263db52e3d95f013c96266a2ac65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 14:48:40 +0200 Subject: [PATCH 20/38] fix(monitor): keep progress total stable across CLI re-queues A full re-queue keeps the CLI done counter, so done + count grew with every rescan (20, 40, 60). Cap the total at the larger of the previous total and the current queue size within a batch. --- .../CodeHealthMonitorNotifierTests.cs | 45 +++++++++++++++++++ .../Services/CodeHealthMonitorNotifier.cs | 13 +++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs index ab8b5baa..f4adf494 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs @@ -134,6 +134,41 @@ public void ApplyQueue_ClearsTotalWhenNothingRemains() 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 Clear_RemovesJobsAndRequestsViewUpdate() { @@ -150,5 +185,15 @@ public void Clear_RemovesJobsAndRequestsViewUpdate() 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/Application/Services/CodeHealthMonitorNotifier.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs index ff931d41..f4d90cdf 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Services/CodeHealthMonitorNotifier.cs @@ -129,7 +129,7 @@ public void ApplyQueue(ReviewQueue queue) else { RemainingCount = remaining; - TotalCount = (queue?.Done ?? 0) + remaining; + TotalCount = NextTotalCount(queue?.Done ?? 0, remaining); AnalysisState = "running"; } @@ -159,6 +159,17 @@ 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(); From 9a2de8666fb790121e14b51c1a14b9a43511035f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 14:48:40 +0200 Subject: [PATCH 21/38] fix(webview): push full monitor state on init Only post to the webview after it sends init, reset the initialized flag on reload, and refresh the home view when it reports ready. --- .../WebComponent/Handlers/WebComponentMessageHandler.cs | 7 ++++++- .../WebComponent/WebComponentUserControl.xaml.cs | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) 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 2803b107..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 @@ -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 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..1632bc85 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 @@ -336,7 +336,6 @@ private async Task InitializeWebView2Async(T payload, string view) webView.NavigationCompleted += (_, _) => { loadingOverlay.Visibility = System.Windows.Visibility.Collapsed; - TryFlushPendingWebMessage(); }; webView.CoreWebView2.WebMessageReceived += (sender, e) => @@ -381,6 +380,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; } From 20da2771459454e906525ea0373a20a5f7cd33fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 14:54:46 +0200 Subject: [PATCH 22/38] refactor: remove code health issues in CLI notification handling Share one handler for fileReview and deltaReview notifications via a ResultNotification base, and drop the redundant numeric branch in LogFields.FormatValue. --- .../Cli/IdeServerClient.Notifications.cs | 51 ++++++++----------- .../Models/Cli/Rpc/DeltaNotification.cs | 9 +--- .../Models/Cli/Rpc/ResultNotification.cs | 15 ++++++ .../Models/Cli/Rpc/ReviewNotification.cs | 9 +--- .../Util/LogFields.cs | 6 --- 5 files changed, 37 insertions(+), 53 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ResultNotification.cs 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 index ff0d6581..a0346c8f 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.Notifications.cs @@ -41,32 +41,21 @@ private void HandleStart(JObject metadata) ServerStarted?.Invoke(this, new ServerStartEvent { Metadata = parsed, Restart = restart }); } - private void HandleReview(JObject notification) + 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, "fileReview"); - if (identity == null) - { - return; - } - - var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); - var result = normalized["result"]?.ToObject(Serializer); - _logger.Info("[cs-ide] received fileReview " + LogFields.Format( - LogFields.Field("id", identity.Item1 ?? "(none)"), - LogFields.Field("path", identity.Item2), - LogFields.Field("repo", identity.Item3)) + " " + IdeServerLog.ReviewSummary(result)); - ReviewReceived?.Invoke(this, new ReviewNotification - { - Id = identity.Item1, - Path = identity.Item2, - RepoRoot = identity.Item3, - Result = result, - }); - } - - private void HandleDelta(JObject notification) - { - var identity = ParseIdentity(notification, "deltaReview"); + var identity = ParseIdentity(notification, kind); if (identity == null) { return; @@ -74,19 +63,19 @@ private void HandleDelta(JObject notification) var normalized = (JObject)RpcJsonNormalizer.ToKebabCase(notification); var resultToken = normalized["result"]; - var delta = resultToken == null || resultToken.Type == JTokenType.Null + var result = resultToken == null || resultToken.Type == JTokenType.Null ? null - : resultToken.ToObject(Serializer); - _logger.Info("[cs-ide] received deltaReview " + LogFields.Format( + : 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)) + " " + IdeServerLog.DeltaSummary(delta)); - DeltaReceived?.Invoke(this, new DeltaNotification + LogFields.Field("repo", identity.Item3)) + " " + summarize(result)); + handler?.Invoke(this, new TNotification { Id = identity.Item1, Path = identity.Item2, RepoRoot = identity.Item3, - Result = delta, + Result = result, }); } 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 index 28973206..2945d8d3 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaNotification.cs @@ -4,14 +4,7 @@ namespace Codescene.VSExtension.Core.Models.Cli.Rpc { - public class DeltaNotification + public class DeltaNotification : ResultNotification { - public string Id { get; set; } - - public string Path { get; set; } - - public string RepoRoot { get; set; } - - public DeltaResponseModel 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/ReviewNotification.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs index 934b7b08..416ec8e1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/ReviewNotification.cs @@ -4,14 +4,7 @@ namespace Codescene.VSExtension.Core.Models.Cli.Rpc { - public class ReviewNotification + public class ReviewNotification : ResultNotification { - public string Id { get; set; } - - public string Path { get; set; } - - public string RepoRoot { get; set; } - - public CliReviewModel Result { get; set; } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs index 746a0d44..accee9a5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/LogFields.cs @@ -61,12 +61,6 @@ public static string FormatCappedList(IReadOnlyList items, int limit = P private static string FormatValue(object value) { - if (value is bool || value is byte || value is short || value is int || value is long - || value is float || value is double || value is decimal) - { - return Convert.ToString(value, CultureInfo.InvariantCulture); - } - if (value is string text) { return text.IndexOf(' ') >= 0 ? JsonConvert.SerializeObject(text) : text; From 60c83a0c9a68228476eec0001f926fb976f4d2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 15:16:09 +0200 Subject: [PATCH 23/38] refactor: remove cs-ide migration dead code and local review RPC Editor reviews use the watch pipeline only; drop legacy git baselines, cs-ide/review and cs-ide/delta, and orphaned migration types. Cancelled ACE refactor and RPC requests are no longer logged or surfaced as failures. --- .../BenchmarkSupport.cs | 61 -- .../CliExecutorBenchmarks.cs | 34 +- .../CodeReviewerBenchmarks.cs | 99 --- .../BatchGitIgnoreCheckerIntegrationTests.cs | 85 --- .../CliExecutor/CodeHealthRulesReviewTests.cs | 402 ------------- .../CliExecutor/ReviewContentTests.cs | 140 ----- .../CliExecutor/ReviewDeltaTests.cs | 84 --- .../TestImplementations/TestGitService.cs | 22 - .../GitServiceSubcutaneousTests.cs | 170 ------ .../RecordingCliExecutor.cs | 98 --- .../RecordingCodeReviewer.cs | 124 ---- .../SubcutaneousGitTestInfrastructure.cs | 39 -- .../AceManagerTests.cs | 19 + .../BaselineReviewCacheServiceTests.cs | 230 ------- .../BatchGitIgnoreCheckerTests.cs | 360 ----------- .../CliExecutorTests.cs | 366 +----------- .../CodeReviewerPipelineRestoreTests.cs | 14 +- .../CodeReviewerTests.cs | 564 +----------------- .../CodesceneRepoConfigTests.cs | 94 --- .../CoreCountUtilsTests.cs | 34 -- .../CpuUsageCheckerTests.cs | 125 ---- .../IdeServerClientTests.cs | 55 +- .../MainBranchMergeBaseSelectorTests.cs | 299 ---------- .../MainBranchNamesTests.cs | 497 --------------- ...sync_NullRawScore_ReturnsNullDeltaTests.cs | 63 -- ...taAsync_ValidRawScore_ReturnsDeltaTests.cs | 69 --- .../SerializationTests.cs | 23 - .../Application/Ace/AceManager.cs | 4 + .../Review/BaselineReviewCacheService.cs | 176 ------ .../Cache/Review/ReviewCacheCleanup.cs | 29 - ...CliExecutor.FnsToRefactorFromCodeSmells.cs | 30 + .../Cli/CliExecutor.FnsToRefactorFromDelta.cs | 31 + .../Application/Cli/CliExecutor.cs | 229 +------ .../Application/Cli/CodeReviewer.cs | 124 +--- .../Application/Cli/IdeServerClient.cs | 19 +- .../Application/Cli/IdeServerLog.cs | 31 - .../Application/Git/CodesceneRepoConfig.cs | 47 -- .../Git/FirstParentChangeCollector.cs | 60 -- .../Git/MainBranchMergeBaseSelector.cs | 126 ---- .../Application/Git/MainBranchNames.cs | 131 ---- .../Application/Util/CpuUsageChecker.cs | 58 -- .../Application/Util/NoOpCpuUsageChecker.cs | 15 - .../Interfaces/Cli/ICliExecutor.cs | 4 - .../Interfaces/Cli/ICodeReviewer.cs | 10 +- .../Interfaces/Cli/IIdeServerClient.cs | 5 - .../Interfaces/Git/IBatchGitIgnoreChecker.cs | 11 - .../Git/IDocumentSaveEventSource.cs | 13 - .../Interfaces/Git/IGitService.cs | 9 - .../Interfaces/IIdeActivityTracker.cs | 11 - .../Interfaces/Util/ICpuUsageChecker.cs | 11 - .../Models/Cli/AttachOutputHandlersArgs.cs | 38 -- .../Models/Cli/Delta/ReviewDeltaRequest.cs | 15 - .../Models/Cli/Review/ReviewRequestModel.cs | 21 - .../Models/Cli/Rpc/DeltaRequestParams.cs | 15 - .../Util/CoreCountUtils.cs | 15 - .../Adapters/IdeActivityTracker.cs | 69 --- .../Adapters/VsDocumentSaveEventSource.cs | 134 ----- .../Cli/CachingCodeReviewerProvider.cs | 32 +- .../Application/Git/BatchGitIgnoreChecker.cs | 159 ----- .../Application/Git/GitService.cs | 172 ------ .../Codescene.VSExtension.VS2022.csproj | 3 - .../Handlers/SolutionEventsHandler.cs | 2 - 62 files changed, 126 insertions(+), 5903 deletions(-) delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/CodeReviewerBenchmarks.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/BatchGitIgnoreCheckerIntegrationTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/CodeHealthRulesReviewTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCliExecutor.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/RecordingCodeReviewer.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BaselineReviewCacheServiceTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/BatchGitIgnoreCheckerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodesceneRepoConfigTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CoreCountUtilsTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CpuUsageCheckerTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchMergeBaseSelectorTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/MainBranchNamesTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_NullRawScore_ReturnsNullDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewWithDeltaAsync_ValidRawScore_ReturnsDeltaTests.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/BaselineReviewCacheService.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cache/Review/ReviewCacheCleanup.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromCodeSmells.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/CliExecutor.FnsToRefactorFromDelta.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/CodesceneRepoConfig.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/FirstParentChangeCollector.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchMergeBaseSelector.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/MainBranchNames.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/CpuUsageChecker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Util/NoOpCpuUsageChecker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IBatchGitIgnoreChecker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Git/IDocumentSaveEventSource.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/IIdeActivityTracker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Util/ICpuUsageChecker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/AttachOutputHandlersArgs.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Delta/ReviewDeltaRequest.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/CoreCountUtils.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/IdeActivityTracker.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VsDocumentSaveEventSource.cs delete mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Git/BatchGitIgnoreChecker.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs index 95739243..e214cfc2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Benchmarks/BenchmarkSupport.cs @@ -1,12 +1,10 @@ // Copyright (c) CodeScene. All rights reserved. -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.Cli.Refactor; using AutoRefactorConfig = Codescene.VSExtension.Core.Models.WebComponent.Data.AutoRefactorConfig; @@ -52,18 +50,6 @@ public CliExecutor CreateCliExecutor() null); } - public CodeReviewer CreateCodeReviewer(IGitService gitService, IPreflightManager? preflightManager = null) - { - return new CodeReviewer( - _logger, - _modelMapper, - CreateCliExecutor(), - null, - gitService, - null, - preflightManager); - } - public void Dispose() { if (_disposed) @@ -181,53 +167,6 @@ internal sealed class FixedSettingsProvider : ISettingsProvider public bool EnableTelemetry => true; } -internal sealed class FixedGitService : IGitService -{ - private readonly string _defaultBaselineContent; - private readonly Dictionary _pathContents = new Dictionary(StringComparer.OrdinalIgnoreCase); - - 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 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() - { - } -} - internal sealed class FixedPreflightManager : IPreflightManager { private readonly PreFlightResponseModel _response; 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.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 92e7acb5..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.IntegrationTests/CliExecutor/ReviewContentTests.cs +++ /dev/null @@ -1,140 +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"); - - var second = await cliExecutor.ReviewContentAsync(filePath, content); - Assert.IsNotNull(second, "Repeated reviews should reuse the persistent server"); - Assert.IsFalse(string.IsNullOrEmpty(second.RawScore)); - } - 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/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.SubcutaneousTests/GitServiceSubcutaneousTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/GitServiceSubcutaneousTests.cs deleted file mode 100644 index 6962115f..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""); - if (true) { if (true) { if (true) { if (true) { if (true) { } } } } } - } - } -}"; - - 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) { if (true) { if (true) { } } } } } } } - if (t1 == t2) { if (t2 == t3) { if (t3 == t4) { if (t4 == t5) { Console.WriteLine(t1); } } } } - } - } -}"; - - [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.IsLessThan(10.00m, 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.IsNotNull(delta); - Assert.IsNotNull(delta.OldScore); - Assert.AreEqual(RoundScore(developReview.Score), RoundScore(delta.OldScore.GetValueOrDefault())); - Assert.AreEqual(RoundScore(featureReview.Score), RoundScore(delta.NewScore)); - Assert.IsLessThan(RoundScore(developReview.Score), RoundScore(featureReview.Score)); - } - - 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/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/SubcutaneousGitTestInfrastructure.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs index 61684ab7..d3a0a6d8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.SubcutaneousTests/SubcutaneousGitTestInfrastructure.cs @@ -2,9 +2,7 @@ using System.Diagnostics; 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.Util; using Codescene.VSExtension.VS2022.Application.Git; @@ -12,20 +10,12 @@ namespace Codescene.VSExtension.Core.SubcutaneousTests; public abstract class SubcutaneousGitTestBase { - private IdeServerClient? _ideServerClient; - protected EventJournal Journal { get; private set; } = null!; protected TestLogger Logger { get; private set; } = null!; - protected RecordingCliExecutor CliExecutor { get; private set; } = null!; - - protected RecordingCodeReviewer CodeReviewer { 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; @@ -37,10 +27,8 @@ public virtual Task InitializeAsync() { CacheGeneration.Reset(); DeltaJobTracker.Clear(); - MainBranchNames.ClearDefaultBranchCache(); Journal = new EventJournal(); Logger = new TestLogger(Journal); - DeltaCache = new DeltaCacheService(); RepositoryRoot = Path.Combine(Path.GetTempPath(), "codescene-subcutaneous-tests", Guid.NewGuid().ToString("N")); CacheDirectory = Path.Combine(RepositoryRoot, ".cache"); @@ -50,21 +38,6 @@ public virtual 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); - - CodeReviewer = new RecordingCodeReviewer(innerReviewer, Journal); return Task.CompletedTask; } @@ -72,12 +45,9 @@ public virtual Task InitializeAsync() public virtual Task CleanupAsync() { GitService?.Dispose(); - _ideServerClient?.Dispose(); if (!string.IsNullOrEmpty(RepositoryRoot) && Directory.Exists(RepositoryRoot)) { - ReviewCacheCleanup.CleanupCaches(RepositoryRoot); - try { Directory.Delete(RepositoryRoot, recursive: true); @@ -183,15 +153,6 @@ private void EnsureGitSucceeded(string arguments, GitCommandResult result) } } - private CliExecutor CreateCliExecutor() - { - var settingsProvider = new TestSettingsProvider(); - var cliSettingsProvider = new CliSettingsProvider(); - var cacheStorage = new TestCacheStorageService(CacheDirectory); - _ideServerClient = new IdeServerClient(cliSettingsProvider, Logger, settingsProvider); - return new CliExecutor(Logger, _ideServerClient, cacheStorage, settingsProvider, null); - } - private void InitializeGitRepository() { ExecGit("init"); 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/CliExecutorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs index 7ec33ec2..5b0f7d20 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CliExecutorTests.cs @@ -1,17 +1,12 @@ // 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 LibGit2Sharp; using Moq; namespace Codescene.VSExtension.Core.Tests @@ -20,8 +15,6 @@ 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 _mockClient; @@ -41,164 +34,12 @@ public void Setup() _mockTelemetryManager = new Mock(); _lazyTelemetryManager = new Lazy(() => _mockTelemetryManager.Object); _mockCacheStorage.Setup(x => x.GetSolutionReviewCacheLocation()).Returns(TestCachePath); - _cliExecutor = CreateExecutor(); - } - - [TestMethod] - public async Task ReviewContentAsync_WithValidResponse_ReturnsCliReviewModel() - { - var expectedReview = new CliReviewModel { Score = 7.5f, RawScore = "base64encoded" }; - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(expectedReview); - - var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); - - Assert.IsNotNull(result); - Assert.AreEqual(expectedReview.Score, result.Score); - Assert.AreEqual(expectedReview.RawScore, result.RawScore); - _mockClient.Verify( - x => x.ReviewAsync( - It.Is(r => r.FilePath == TestFilePath && r.FileContent == TestFileContent && r.CachePath == TestCachePath), - It.IsAny()), - Times.Once); - } - - [TestMethod] - public async Task ReviewContentAsync_FileInGitRepository_IncludesRepositoryRoot() - { - var repositoryRoot = Path.Combine(Path.GetTempPath(), "cli-review-repo-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(repositoryRoot); - Repository.Init(repositoryRoot); - var filePath = Path.Combine(repositoryRoot, "test.cs"); - File.WriteAllText(filePath, TestFileContent); - ReviewRequestModel request = null; - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .Callback((value, _) => request = value) - .ReturnsAsync(new CliReviewModel()); - - try - { - await _cliExecutor.ReviewContentAsync(filePath, TestFileContent); - - var repoPathProperty = typeof(ReviewRequestModel).GetProperty("RepoPath"); - Assert.IsNotNull(repoPathProperty); - Assert.AreEqual( - Path.GetFullPath(repositoryRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - repoPathProperty.GetValue(request)); - } - finally - { - Directory.Delete(repositoryRoot, recursive: true); - } - } - - [TestMethod] - public async Task ReviewContentAsync_FileOutsideGitRepository_IncludesContainingDirectory() - { - var directory = Path.Combine(Path.GetTempPath(), "cli-review-file-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - var filePath = Path.Combine(directory, "test.cs"); - File.WriteAllText(filePath, TestFileContent); - ReviewRequestModel request = null; - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .Callback((value, _) => request = value) - .ReturnsAsync(new CliReviewModel()); - - try - { - await _cliExecutor.ReviewContentAsync(filePath, TestFileContent); - - Assert.AreEqual(directory, request.RepoPath); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - - [TestMethod] - public async Task ReviewContentAsync_WhenClientThrowsDevtoolsException_ThrowsException() - { - _mockClient.Setup(x => x.ReviewAsync(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_WhenClientThrowsRefactoringCreditsDevtoolsException_LogsWarningNotError() - { - const string creditsMessage = - "Your credits of refactoring functionality ran out. Buy a bigger plan."; - _mockClient.Setup(x => x.ReviewAsync(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_WhenClientThrowsGenericException_ReturnsNull() - { - _mockClient.Setup(x => x.ReviewAsync(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_WhenClientReturnsNull_ReturnsNull() - { - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((CliReviewModel)null); - - var result = await _cliExecutor.ReviewContentAsync(TestFilePath, TestFileContent); - - Assert.IsNull(result); - } - - [TestMethod] - public async Task ReviewDeltaAsync_WithValidResponse_ReturnsDeltaResponseModel() - { - var expectedDelta = new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m, ScoreChange = 1.5m }; - _mockClient.Setup(x => x.DeltaAsync("old-score-b64", "new-score-b64", It.IsAny())) - .ReturnsAsync(expectedDelta); - - var result = await _cliExecutor.ReviewDeltaAsync(new ReviewDeltaRequest - { - OldScore = "old-score-b64", - NewScore = "new-score-b64", - 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_WhenClientThrowsException_ReturnsNull() - { - _mockClient.Setup(x => x.DeltaAsync(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); + _cliExecutor = new CliExecutor( + _mockLogger.Object, + _mockClient.Object, + _mockCacheStorage.Object, + _mockSettingsProvider.Object, + _lazyTelemetryManager); } [TestMethod] @@ -265,200 +106,5 @@ public void Constructor_WithNullTelemetryManagerLazy_DoesNotThrow() Assert.IsNotNull(executor); } - - [TestMethod] - public async Task ReviewContentAsync_WhenCancelled_ReturnsNull() - { - var completion = new TaskCompletionSource(); - _mockClient.Setup(x => x.ReviewAsync(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() - { - var firstCompletion = new TaskCompletionSource(); - var callCount = 0; - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .Returns((_, ct) => - { - callCount++; - if (callCount == 1) - { - return firstCompletion.Task; - } - - return Task.FromResult(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; - - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .Returns(async (request, _) => - { - Interlocked.Increment(ref callCount); - startedSignal.TrySetResult(true); - await completion.Task; - return new CliReviewModel { Score = 7.5f, RawScore = request.FilePath }; - }); - - 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 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 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 = CreateExecutor(mockThrottler.Object, 1); - _mockClient.Setup(x => x.ReviewAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(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 = CreateExecutor(mockThrottler.Object, 1); - _mockClient.Setup(x => x.DeltaAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DeltaResponseModel { NewScore = 8.5m, OldScore = 7.0m }); - - 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); - } - - private CliExecutor CreateExecutor(ICpuUsageThrottler throttler = null, int concurrency = 1) - { - return new CliExecutor( - _mockLogger.Object, - _mockClient.Object, - _mockCacheStorage.Object, - _mockSettingsProvider.Object, - _lazyTelemetryManager, - throttler, - concurrency); - } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs index 2c04dfda..fe35a5ed 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerPipelineRestoreTests.cs @@ -3,8 +3,6 @@ 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; @@ -19,8 +17,6 @@ public class CodeReviewerPipelineRestoreTests private Mock _mockLogger; private Mock _mockMapper; private Mock _mockExecutor; - private Mock _mockTelemetryManager; - private Mock _mockGitService; [TestInitialize] public void Setup() @@ -28,8 +24,6 @@ public void Setup() _mockLogger = new Mock(); _mockMapper = new Mock(); _mockExecutor = new Mock(); - _mockTelemetryManager = new Mock(); - _mockGitService = new Mock(); } [TestMethod] @@ -50,9 +44,7 @@ public async Task ReviewWithDeltaAsync_WhenContentMatchesDisk_RestoresBufferOwne _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - pipeline: pipeline.Object); + pipeline.Object); try { @@ -88,9 +80,7 @@ public async Task ReviewWithDeltaAsync_WhenContentDiffersFromDisk_DoesNotRestore _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - pipeline: pipeline.Object); + pipeline.Object); try { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs index b5fd431a..0c21a736 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs @@ -2,13 +2,9 @@ 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.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.Refactor; using Codescene.VSExtension.Core.Models.Cli.Review; using Codescene.VSExtension.Core.Models.Cli.Rpc; using Moq; @@ -21,9 +17,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() @@ -31,555 +24,6 @@ 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() - { - var path = "test.cs"; - var content = string.Empty; - - var result = await _codeReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ReviewAsync_WhitespaceContent_ReturnsNull() - { - var path = "test.cs"; - var content = " "; - - var result = await _codeReviewer.ReviewAsync(path, content); - - Assert.IsNull(result); - _mockExecutor.Verify(x => x.ReviewContentAsync(It.IsAny(), It.Is(s => string.IsNullOrWhiteSpace(s)), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ReviewAsync_ValidInput_CallsExecutorAndMapper() - { - // 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); - - // 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); - } - - [TestMethod] - public async Task ReviewAsync_PassesFullPathToExecutor() - { - // Arrange - var path = "C:/some/deep/path/to/MyFile.cs"; - var content = "code"; - 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); - _mockMapper.Setup(x => x.Map(path, cliReview)).Returns(review); - - 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 - { - 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); - } - - [TestMethod] - public async Task DeltaAsync_WithPreflightManager_NullRefactorables_ReturnsDeltaUnchanged() - { - 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 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); - - 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); - } - - [TestMethod] - public async Task DeltaAsync_WithPreflightManager_NonMatchingRefactorables_DoesNotAssign() - { - 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, - FunctionLevelFindings = new[] - { - new FunctionFindingModel { Function = new FunctionInfoModel { Name = "Bar" } }, - }, - }; - - 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(new List { new FnToRefactorModel { Name = "Unrelated" } }); - - var result = await reviewer.DeltaAsync(review, currentCode); - - Assert.IsNotNull(result); - Assert.IsNull(result.FunctionLevelFindings[0].RefactorableFn); } [TestMethod] @@ -590,9 +34,7 @@ public async Task ReviewWithDeltaAsync_WithPipelineOutsideRepository_ReturnsNull _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - pipeline: pipeline.Object); + pipeline.Object); var result = await reviewer.ReviewWithDeltaAsync( Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "test.cs"), @@ -626,9 +68,7 @@ public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() _mockLogger.Object, _mockMapper.Object, _mockExecutor.Object, - _mockTelemetryManager.Object, - _mockGitService.Object, - pipeline: pipeline.Object); + pipeline.Object); try { 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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index cabceb59..c1b21c48 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -83,35 +83,6 @@ public async Task StartAsync_WhenShaDoesNotMatch_Throws() Assert.Contains("version mismatch", exception.Message); } - [TestMethod] - public async Task ReviewAsync_NormalizesCamelCaseResponse() - { - using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); - await harness.Client.StartAsync(); - - var review = await harness.Client.ReviewAsync(new ReviewRequestModel - { - FilePath = "src/a.cs", - FileContent = "class A {}", - }); - - Assert.AreEqual("raw", review.RawScore); - Assert.AreEqual(9.68f, review.Score); - Assert.AreEqual("review-sha", review.GitBlobSha); - } - - [TestMethod] - public async Task RepeatedReviews_UseSingleProcess() - { - using var harness = await IdeServerTestHarness.StartAsync(_settings, _logger, _userSettings); - await harness.Client.StartAsync(); - - await harness.Client.ReviewAsync(new ReviewRequestModel { FilePath = "src/a.cs", FileContent = "class A {}" }); - await harness.Client.ReviewAsync(new ReviewRequestModel { FilePath = "src/b.cs", FileContent = "class B {}" }); - - Assert.AreEqual(1, harness.Factory.StartCount); - } - [TestMethod] public async Task StartAsync_LogsCliStderrToCodeSceneLog() { @@ -135,32 +106,36 @@ public async Task DeviceIdAsync_ReadsCamelCaseOrKebabCase() } [TestMethod] - public async Task ReviewAsync_HttpFailureMessage_UsesHttpStatusInDevtoolsException() + 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); - var exception = await Assert.ThrowsAsync(() => harness.Client.ReviewAsync(new ReviewRequestModel - { - FilePath = "src/a.cs", - FileContent = "trigger-http-401", - })); - - Assert.AreEqual(401, exception.Status); - Assert.Contains("Request failed [401]", exception.Message); + await Assert.ThrowsAsync(() => harness.Client.RefactorAsync( + new RefactorPostRequestModel { Token = "delay-cancel" }, + cancellation.Token)); } [TestMethod] - public async Task RefactorAsync_WhenCancelled_DoesNotThrowDevtoolsException() + 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.CancelAfter(50); + 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] 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/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/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/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/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/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 bbfb79c8..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,7 +15,6 @@ 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; @@ -28,18 +26,14 @@ 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 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( @@ -47,139 +41,13 @@ public CliExecutor( IIdeServerClient client, ICacheStorageService cacheStorage, ISettingsProvider settingsProvider, - [Import(AllowDefault = true)] Lazy telemetryManagerLazy = null, - [Import(AllowDefault = true)] ICpuUsageThrottler cpuUsageThrottler = null) - : this(logger, client, cacheStorage, settingsProvider, telemetryManagerLazy, cpuUsageThrottler, 1) - { - } - - internal CliExecutor( - ILogger logger, - IIdeServerClient client, - ICacheStorageService cacheStorage, - ISettingsProvider settingsProvider, - Lazy telemetryManagerLazy, - ICpuUsageThrottler cpuUsageThrottler, - int cliCommandConcurrencyLimit) + [Import(AllowDefault = true)] Lazy telemetryManagerLazy = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _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); - } - - 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); - - _inFlightReviewCancellation.AddOrUpdate(key, cts, (_, existing) => - { - try - { - existing.Cancel(); - } - catch (ObjectDisposedException) - { - } - - existing.Dispose(); - return cts; - }); - - try - { - var request = new ReviewRequestModel - { - FilePath = filePath, - FileContent = content, - CachePath = _cacheStorage.GetSolutionReviewCacheLocation(), - RepoPath = GetReviewRepoPath(filePath), - }; - - var (result, elapsedMs) = await ExecuteOnChannelAsync( - _cliCommandChannel, - cts.Token, - () => ExecuteWithTimingAndLoggingAsync( - "CLI file review", - () => _client.ReviewAsync(request, cts.Token), - $"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) - { - await _deltaChannel.WaitAsync(cancellationToken); - try - { - await _cpuUsageThrottler.WaitForCpuAsync(cancellationToken); - var (result, elapsedMs) = await ExecuteWithTimingAndLoggingAsync( - "CLI file delta review", - () => _client.DeltaAsync(request.OldScore, request.NewScore, cancellationToken), - "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) @@ -244,40 +112,6 @@ 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.", - createRequest: cachePath => new FnsToRefactorCodeSmellRequestModel - { - FileName = fileName, - FileContent = fileContent, - CachePath = cachePath, - Preflight = preflight, - CodeSmells = codeSmells, - }, - 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) - { - return await ExecuteFnsToRefactorAsync( - isValid: deltaResult != null, - skipMessage: "Skipping refactoring functions from delta. Delta result was null.", - createRequest: cachePath => new FnsToRefactorDeltaRequestModel - { - FileName = fileName, - FileContent = fileContent, - CachePath = cachePath, - Preflight = preflight, - DeltaResult = deltaResult, - }, - operationLabel: "ACE refactoring functions from delta check", - cancellationToken: cancellationToken); - } - public Task GetDeviceIdAsync(CancellationToken cancellationToken = default) { return TryReadServerValueAsync( @@ -298,21 +132,6 @@ public Task GetFileVersionAsync(CancellationToken cancellationToken = de 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 TryReadServerValueAsync( Func> read, string errorMessage, @@ -358,36 +177,6 @@ private void SendPerformanceTelemetry(PerformanceTelemetryData telemetryData) }); } - private string GetReviewRepoPath(string filePath) - { - var gitRoot = GitPathDiscovery.TryGetWorkingDirectory(filePath); - if (!string.IsNullOrEmpty(gitRoot)) - { - return gitRoot; - } - - var workspace = _cacheStorage.GetWorkspaceDirectory(); - if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) - { - return PathNormalization.NormalizeWorkingDirectory(workspace); - } - - if (string.IsNullOrWhiteSpace(filePath)) - { - return null; - } - - try - { - return Path.GetDirectoryName(Path.GetFullPath(filePath)); - } - catch (Exception ex) - { - _logger.Debug($"Could not resolve review repository path: {ex.Message}"); - return null; - } - } - private async Task> ExecuteFnsToRefactorAsync( bool isValid, string skipMessage, @@ -445,20 +234,6 @@ private string GetPendingRefactorRequestKey(string operationLabel, FnsToRefactor } } - 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) 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 d9468703..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,13 +5,10 @@ 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.Extension; -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.Rpc; @@ -24,9 +21,6 @@ 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; @@ -35,112 +29,21 @@ public CodeReviewer( ILogger logger, IModelMapper mapper, ICliExecutor executor, - ITelemetryManager telemetryManager, - IGitService git, - ICodeHealthMonitorNotifier notifier = null, + IReviewPipeline pipeline, IPreflightManager preflightManager = null, - IReviewPipeline pipeline = 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; - _pipeline = pipeline; _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); - - if (string.IsNullOrWhiteSpace(fileName) || string.IsNullOrWhiteSpace(content)) - { - _logger.Debug($"Skipping review for '{path}'. Missing content or file path."); - return null; - } - - _logger?.Info($"Reviewing file {path}...", true); - - var review = await _executor.ReviewContentAsync(path, content, isBaseline, cancellationToken); - return _mapper.Map(path, review); - } - - 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); - } - - public async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithDeltaAsync(string path, string content, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - if (_pipeline != null) - { - return await ReviewWithPipelineAsync(path, content, cancellationToken); - } - - 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 DeltaAsync(FileReviewModel review, string currentCode, string precomputedBaselineRawScore = null, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var path = review.FilePath; - var currentRawScore = review.RawScore ?? string.Empty; - - if (string.IsNullOrWhiteSpace(path)) - { - _logger.Warn("Could not review file, missing file path."); - return null; - } - - 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(); - return await EnrichDeltaAsync(path, currentCode, delta, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - _logger.Error($"Could not perform delta analysis on file {path}", e); - return null; - } - } - - public async Task GetOrComputeBaselineRawScoreAsync(string path, string baselineContent, long? operationGeneration = null, CancellationToken cancellationToken = default, string baselineCommit = null) - { - var oldCode = !string.IsNullOrEmpty(baselineContent) - ? baselineContent - : (_git?.GetFileContentForCommit(path, baselineCommit) ?? string.Empty); - - if (string.IsNullOrEmpty(oldCode)) - { - return string.Empty; - } - - return await GetOrComputeBaselineRawScoreInternalAsync(path, oldCode, operationGeneration, cancellationToken); + return ReviewWithPipelineAsync(path, content, cancellationToken); } private async Task<(FileReviewModel review, DeltaResponseModel delta)> ReviewWithPipelineAsync(string path, string content, CancellationToken cancellationToken) @@ -237,24 +140,5 @@ private async Task EnrichDeltaAsync(string path, string curr return delta; } - - 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) - { - return baselineEntry.RawScore ?? string.Empty; - } - - var oldCodeReview = await 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; - } } } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs index f4e10026..d63513c1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerClient.cs @@ -106,19 +106,6 @@ public async Task RestartAsync(CancellationToken cancellationToken = default) await StartAsync(cancellationToken).ConfigureAwait(false); } - public Task ReviewAsync(ReviewRequestModel request, CancellationToken cancellationToken = default) - { - return SendRequestAsync("cs-ide/review", request, cancellationToken); - } - - public Task DeltaAsync(string oldScore, string newScore, CancellationToken cancellationToken = default) - { - return SendRequestAsync( - "cs-ide/delta", - new DeltaRequestParams { OldScore = oldScore, NewScore = newScore }, - cancellationToken); - } - public Task PreflightAsync(bool force = false, CancellationToken cancellationToken = default) { return SendRequestAsync( @@ -362,6 +349,12 @@ private async Task SendRequestAsync(string method, object argument, Cancel _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( diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs index 52296e61..151a27c0 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/IdeServerLog.cs @@ -18,8 +18,6 @@ internal static class IdeServerLog private static readonly Dictionary> RequestSummaries = new Dictionary> { - { "cs-ide/review", SummarizeReviewRequest }, - { "cs-ide/delta", SummarizeDeltaRequest }, { "cs-ide/preflight", SummarizePreflightRequest }, { "cs-ide/fns-to-refactor", argument => SummarizeFnsRequest(argument as FnsToRefactorRequestModel) }, { "cs-ide/refactor", argument => SummarizeRefactorRequest(argument as RefactorPostRequestModel) }, @@ -31,8 +29,6 @@ internal static class IdeServerLog private static readonly Dictionary> ResponseSummaries = new Dictionary> { - { "cs-ide/review", argument => ReviewSummary(argument as CliReviewModel) }, - { "cs-ide/delta", argument => DeltaSummary(argument as DeltaResponseModel) }, { "cs-ide/preflight", SummarizePreflightResponse }, { "cs-ide/refactor", SummarizeRefactorResponse }, { "cs-ide/device-id", argument => LogFields.Format(LogFields.Field("ok", true)) }, @@ -111,33 +107,6 @@ private static string Summarize(Dictionary> summari return string.Empty; } - private static string SummarizeReviewRequest(object argument) - { - var review = argument as ReviewRequestModel; - if (review == null) - { - return string.Empty; - } - - return LogFields.Format( - LogFields.Field("path", review.FilePath), - LogFields.Field("hasContent", review.FileContent != null), - LogFields.Field("hasCachePath", review.CachePath != null)); - } - - private static string SummarizeDeltaRequest(object argument) - { - var delta = argument as DeltaRequestParams; - if (delta == null) - { - return string.Empty; - } - - return LogFields.Format( - LogFields.Field("hasOldScore", delta.OldScore != null), - LogFields.Field("hasNewScore", delta.NewScore != null)); - } - private static string SummarizePreflightRequest(object argument) { var preflight = argument as PreflightRequestParams; 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/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/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/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/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/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/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 index 440acf7f..527c9d67 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Interfaces/Cli/IIdeServerClient.cs @@ -4,7 +4,6 @@ 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; using Codescene.VSExtension.Core.Models.Cli.Rpc; @@ -33,10 +32,6 @@ public interface IIdeServerClient : IDisposable Task RestartAsync(CancellationToken cancellationToken = default); - Task ReviewAsync(ReviewRequestModel request, CancellationToken cancellationToken = default); - - Task DeltaAsync(string oldScore, string newScore, CancellationToken cancellationToken = default); - Task PreflightAsync(bool force = false, CancellationToken cancellationToken = default); Task> FnsToRefactorAsync(FnsToRefactorRequestModel request, CancellationToken cancellationToken = default); 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/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/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/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/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/ReviewRequestModel.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs deleted file mode 100644 index a60f2b34..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Review/ReviewRequestModel.cs +++ /dev/null @@ -1,21 +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; } - - [JsonProperty("repo-path")] - public string RepoPath { get; set; } - } -} diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs deleted file mode 100644 index 6ee9457a..00000000 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Models/Cli/Rpc/DeltaRequestParams.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) CodeScene. All rights reserved. - -using Newtonsoft.Json; - -namespace Codescene.VSExtension.Core.Models.Cli.Rpc -{ - internal class DeltaRequestParams - { - [JsonProperty("old-score", NullValueHandling = NullValueHandling.Ignore)] - public string OldScore { get; set; } - - [JsonProperty("new-score", NullValueHandling = NullValueHandling.Ignore)] - public string NewScore { 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.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/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 9d950ee6..7a8a7ef6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Cli/CachingCodeReviewerProvider.cs @@ -4,13 +4,10 @@ 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.Extension; -using Codescene.VSExtension.Core.Interfaces.Git; -using Codescene.VSExtension.Core.Interfaces.Telemetry; using Codescene.VSExtension.Core.Models; using Codescene.VSExtension.Core.Models.Cli.Delta; @@ -27,39 +24,16 @@ public CachingCodeReviewerProvider( ILogger logger, IModelMapper mapper, ICliExecutor executor, - ITelemetryManager telemetryManager, - IGitService git, IPreflightManager preflightManager, IReviewPipeline pipeline, - CodeHealthMonitorNotifier notifier, ISettingsProvider settings) { - _inner = new CodeReviewer(logger, mapper, executor, telemetryManager, git, notifier, preflightManager, pipeline, settings); + _inner = new CodeReviewer(logger, mapper, executor, pipeline, preflightManager, settings); } - public 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) { - 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); + 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/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/Codescene.VSExtension.VS2022.csproj b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj index 335fc195..256a72a1 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022.csproj @@ -63,9 +63,7 @@ - - @@ -76,7 +74,6 @@ - diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs index bc80bae4..1ced8fd2 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/SolutionEventsHandler.cs @@ -80,7 +80,6 @@ public int OnAfterCloseSolution(object pUnkReserved) { var cache = new DeltaCacheService(); cache.Clear(); - new BaselineReviewCacheService().Clear(); new ReviewCacheService().Clear(); new AceRefactorableFunctionsCacheService().Clear(); CacheGeneration.Increment(); @@ -575,7 +574,6 @@ private void ResetWorkspaceForInitialization() private void ClearAnalysisCaches() { new DeltaCacheService().Clear(); - new BaselineReviewCacheService().Clear(); new ReviewCacheService().Clear(); new AceRefactorableFunctionsCacheService().Clear(); _codeHealthMonitorNotifier?.Clear(); From 013dbb222efae38d97f2ad0029bd21261d465b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 15:35:36 +0200 Subject: [PATCH 24/38] test: raise changed-code coverage for persistent CLI paths Add targeted unit tests for IdeServerLog, notifier, debounce, pipeline notifications, and IdeServerClient error paths; serialize DeltaJobTracker-related tests to avoid parallel static-state flakes. --- .../CodeHealthMonitorNotifierTests.cs | 56 +++++ .../DebounceServiceTests.cs | 45 +++++ .../DeltaJobTrackerTests.cs | 1 + .../IdeServerClientTests.cs | 53 +++++ .../IdeServerLogTests.cs | 191 ++++++++++++++++++ ...ReviewPipelineNotificationCoverageTests.cs | 151 ++++++++++++++ 6 files changed, 497 insertions(+) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerLogTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs index f4adf494..b3a0ccd5 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeHealthMonitorNotifierTests.cs @@ -8,6 +8,7 @@ namespace Codescene.VSExtension.Core.Tests { [TestClass] + [DoNotParallelize] public class CodeHealthMonitorNotifierTests { private CodeHealthMonitorNotifier _notifier; @@ -169,6 +170,61 @@ public void ApplyQueue_NewBatchAfterDrain_StartsFromDoneAndRemaining() 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() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs index e61305c2..a0a7ac39 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs @@ -154,6 +154,51 @@ public async Task Throttle_EmitsLeadingImmediatelyAndTrailingWhileBusy() 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 Dispose_CanBeCalledMultipleTimes() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DeltaJobTrackerTests.cs index 9a9967e5..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] diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index c1b21c48..b9f36ee8 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -156,6 +156,48 @@ public async Task RefactoringRpcEndpoints_NormalizeResponses() 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 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() { @@ -448,6 +490,12 @@ public JObject Review(JObject request) 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) { @@ -486,6 +534,11 @@ public JObject CodeHealthRulesTemplate(JObject request) [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"":[]}"); } 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/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs new file mode 100644 index 00000000..f39495c2 --- /dev/null +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) CodeScene. All rights reserved. + +using Codescene.VSExtension.Core.Application.Cli; +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 Moq; + +namespace Codescene.VSExtension.Core.Tests +{ + [TestClass] + public class ReviewPipelineNotificationCoverageTests + { + private const string RepoRoot = "/repo"; + private Mock _client; + private ReviewPipelinePresentationEvents _events; + private ReviewPipeline _pipeline; + + [TestInitialize] + public void Setup() + { + _client = new Mock(); + _events = new ReviewPipelinePresentationEvents(); + _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 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; + } + } +} From 99d438758258e9f56c061e627e9efab166a73c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 15:43:58 +0200 Subject: [PATCH 25/38] test: cover CodeReviewer, pipeline notifications, and watch admission Exercise delta enrichment, watch failure paths, process exit handling, and disabled watch admission to clear the remaining changed-code coverage gap. --- .../CodeReviewerTests.cs | 135 ++++++++++++++++++ .../IdeServerClientTests.cs | 14 ++ ...ReviewPipelineNotificationCoverageTests.cs | 115 +++++++++++++++ .../WorkspaceWatchCoordinatorTests.cs | 23 +++ 4 files changed, 287 insertions(+) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs index 0c21a736..19e0f575 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/CodeReviewerTests.cs @@ -2,9 +2,12 @@ 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.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; @@ -95,5 +98,137 @@ public async Task ReviewWithDeltaAsync_WithPipelineReturnsMappedReviewAndDelta() Directory.Delete(repoRoot, true); } } + + [TestMethod] + public async Task ReviewWithDeltaAsync_WhenPipelineReturnsNull_ReturnsNull() + { + 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); + + 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 ReviewWithDeltaAsync_WhenRawScoreMissing_ReturnsReviewWithoutDelta() + { + 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(); + 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); + + try + { + 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 ReviewWithDeltaAsync_WhenAutomaticAnalysisDisabled_SkipsMonitorUpdate() + { + 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, + pipeline.Object, + settings: settings.Object); + + 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 ReviewWithDeltaAsync_EnrichesDeltaWithRefactorableFunctions() + { + 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 + { + FunctionLevelFindings = new[] + { + 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, + pipeline.Object, + preflight.Object); + + 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/IdeServerClientTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs index b9f36ee8..fb014f81 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientTests.cs @@ -182,6 +182,20 @@ public async Task CheckRulesAsync_WhenRpcFails_ThrowsDevtoolsExceptionAndLogsErr 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() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs index f39495c2..ccffed88 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -1,6 +1,7 @@ // 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.Extension; using Codescene.VSExtension.Core.Models.Cli.Delta; @@ -72,6 +73,120 @@ public async Task PendingDelta_WhenAutomaticAnalysisDisabled_PresentsWithoutMoni 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 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 PendingReview_IgnoresMismatchedGitBlobSha() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index 8a849ed3..ceb04b52 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -519,6 +519,29 @@ public async Task SetEnabled_StopsWatchesUntilAnalysisStartsAgain() } } + [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() { From b2fa8ce075dee3f27f6e3e78e04217fb28aeb9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 15:55:39 +0200 Subject: [PATCH 26/38] test: close remaining coverage gaps on watch and RpcPath Cover delta admission rejection, pipeline dedup and epoch drops, and RpcPath edge cases to pass the 95% changed-code gate. --- ...ReviewPipelineNotificationCoverageTests.cs | 58 +++++++++++++++++++ .../RpcPathTests.cs | 30 ++++++++++ .../WorkspaceWatchCoordinatorTests.cs | 28 +++++++++ 3 files changed, 116 insertions(+) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs index ccffed88..ae34c520 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -17,6 +17,7 @@ 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] @@ -24,6 +25,9 @@ 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); } @@ -110,6 +114,37 @@ public async Task WatchDelta_WhenFilterRejects_IsIgnored() 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() { @@ -187,6 +222,29 @@ 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 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 PendingReview_IgnoresMismatchedGitBlobSha() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs index 897ace11..22b99a8f 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs @@ -9,6 +9,36 @@ 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() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index ceb04b52..bdf9855e 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -519,6 +519,34 @@ public async Task SetEnabled_StopsWatchesUntilAnalysisStartsAgain() } } + [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() { From 690f1ba714d66ec76134f19d8bd9d1211662fd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 16:05:53 +0200 Subject: [PATCH 27/38] test: push changed-code coverage over 95% threshold Add restart, native process, watch queue, and pipeline invalidate tests targeting remaining uncovered lines. --- .../IdeServerClientRestartTests.cs | 107 ++++++++++++++++++ .../NativeIdeServerProcessTests.cs | 31 +++++ ...ReviewPipelineNotificationCoverageTests.cs | 10 ++ .../WorkspaceWatchCoordinatorQueueTests.cs | 79 +++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/IdeServerClientRestartTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/NativeIdeServerProcessTests.cs create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorQueueTests.cs 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/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/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs index ae34c520..36ef9d8d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -236,6 +236,16 @@ public async Task Submit_ReusesPendingSubmissionForIdenticalContent() 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() { 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; + } + } +} From 4832f385f341f95a84165b634912e94475c45b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 16:19:42 +0200 Subject: [PATCH 28/38] Add tests for watch cache eviction and RpcPath cache hit. Exercise MaxWatchReviews eviction and canonical repo root caching to raise changed-code coverage toward the CI threshold. --- ...ReviewPipelineNotificationCoverageTests.cs | 99 +++++++++++++++++++ .../RpcPathTests.cs | 18 ++++ 2 files changed, 117 insertions(+) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs index 36ef9d8d..50ebd05b 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -7,6 +7,7 @@ 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 @@ -255,6 +256,89 @@ public void ReleaseBufferMonitorOwnership_AllowsResubmitWithMonitor() 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_IgnoresMismatchedGitBlobSha() { @@ -330,5 +414,20 @@ private IReviewPipelineFileAccess FileAccess(ReviewDocument document, bool visib 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/RpcPathTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs index 22b99a8f..ab8877a4 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs @@ -47,6 +47,24 @@ public void CanonicalRepoRoot_NonExistentPath_FallsBackToNormalizeFsPath() 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() { From 2490b0b83b06c53e416f97917f05ead4a1253d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 12 Oct 2026 16:55:04 +0200 Subject: [PATCH 29/38] Add tests to clear 95% changed-code coverage gate. Cover pipeline mismatch paths, disk file access, RpcPath resolution, debounce cancel paths, and watch coordinator no-op enable. --- .github/coverage-mine.ps1 | 3 +- .../DebounceServiceTests.cs | 21 +++ ...ReviewPipelineNotificationCoverageTests.cs | 129 ++++++++++++++++++ .../RpcPathTests.cs | 24 ++++ .../WorkspaceWatchCoordinatorTests.cs | 9 ++ 5 files changed, 184 insertions(+), 2 deletions(-) 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/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs index a0a7ac39..c9e584f7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/DebounceServiceTests.cs @@ -199,6 +199,27 @@ public async Task Cancel_PreventsDebouncedAction() 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/ReviewPipelineNotificationCoverageTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs index 50ebd05b..dba97b6d 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineNotificationCoverageTests.cs @@ -1,5 +1,6 @@ // 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; @@ -339,6 +340,134 @@ public async Task WatchReview_EvictsOldestCachedEntries() 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() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs index ab8877a4..f125c783 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/RpcPathTests.cs @@ -83,6 +83,30 @@ public void ToLocalRepoRoot_RoundTripsRememberedRoot() } } + [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() { diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs index bdf9855e..5a0591c7 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorTests.cs @@ -490,6 +490,15 @@ public async Task StopAll_CancelsPendingInventoryRefresh() } } + [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() { From b558a44e4f82fedce9f9fa422c7a8da2d7a10485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Tue, 13 Oct 2026 08:28:42 +0200 Subject: [PATCH 30/38] Bump VSIX version to 0.9.0 for test release. --- .../Codescene.VSExtension.VS2022/source.extension.cs | 2 +- .../Codescene.VSExtension.VS2022/source.extension.vsixmanifest | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs index 07f04372..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.5"; + 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 07050456..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 From c44263b009bb4f19b9420391ec325c0544cf7598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 16 Oct 2026 13:33:03 +0200 Subject: [PATCH 31/38] Pin Newtonsoft.Json to 13.0.3 so telemetry uses the API Visual Studio 2022 loads. --- .../Codescene.VSExtension.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 45c78941..e0616c78 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Codescene.VSExtension.Core.csproj @@ -18,7 +18,7 @@ - + analyzers From 646f74274128635df5e502880eb9ae31ea92ee45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Mon, 19 Oct 2026 11:36:56 +0200 Subject: [PATCH 32/38] docs: document CLI baseline resolution since 0.9.0 Explain merge-base selection, baseline_branch config, and migration from the extension-side selector. --- README.md | 4 +- docs/baseline-resolution.md | 159 ++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 docs/baseline-resolution.md 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/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`. From cf1b4f6e18607cd74a1e2656103210bd0a4b8f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Tue, 20 Oct 2026 16:05:22 +0200 Subject: [PATCH 33/38] fix(webview): recreate WebView2 when the host dies in tool windows Reused documentation and ACE panes kept a dead WebView2 and queued updates forever; detect loss of CoreWebView2, rebuild the control, and handle process failures. --- .../WebComponentUserControl.xaml.cs | 125 +++++++++++++++--- 1 file changed, 109 insertions(+), 16 deletions(-) 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 1632bc85..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,20 +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; + loadingOverlay.Visibility = Visibility.Collapsed; }; - webView.CoreWebView2.WebMessageReceived += (sender, e) => + core.WebMessageReceived += (_, e) => { _ = OnWebMessageReceivedAsync(e); }; @@ -348,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: /// From 6b7a8281a2a352196cfac8217f6798dcc25bae2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Tue, 20 Oct 2026 16:27:40 +0200 Subject: [PATCH 34/38] fix(pipeline): keep monitor in sync for open files with a BOM Watch and disk results for open, unmodified files were always dropped as stale because the expected sha was computed from the buffer text, which lacks the UTF-8 BOM the CLI hashes on disk. Compare against the disk bytes when the buffer text matches the disk. When a watch result is dropped because the open buffer is behind disk (e.g. after a branch switch before VS reloads), claim buffer ownership so the disk state is re-requested once the buffer matches disk again. --- .../ReviewPipelineOwnershipTests.cs | 56 +++++++++++++++++++ .../ReviewPipelineTests.cs | 20 +++++++ .../Cli/ReviewPipeline.Notifications.cs | 25 +++++++-- .../Application/Cli/ReviewPipeline.cs | 20 +++++++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs index 181d185f..b170c89c 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineOwnershipTests.cs @@ -81,6 +81,35 @@ public void ReleaseBufferMonitorOwnership_SkipsDiskRestore() 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() { @@ -94,6 +123,33 @@ public async Task WatchDelta_OutsideInventory_IsIgnored() 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;"); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs index 25986b70..490d8469 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/ReviewPipelineTests.cs @@ -255,6 +255,26 @@ public async Task WatchReview_DiscardsStaleGitBlobSha() 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() { 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 index 084e8674..ea109384 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.Notifications.cs @@ -309,7 +309,7 @@ private async Task WatchPresentationAsync(string repoRoot, str return null; } - var expectedSha = await CurrentShaAsync(document, filePath).ConfigureAwait(false); + var (expectedSha, fromBuffer) = await CurrentShaAsync(document, filePath).ConfigureAwait(false); if (receivedSha != null && receivedSha != expectedSha) { _logger?.Warn("[pipeline] ignoring watch result " + LogFields.Format( @@ -318,6 +318,11 @@ private async Task WatchPresentationAsync(string repoRoot, str LogFields.Field("reason", "stale-sha"), LogFields.Field("receivedSha", receivedSha), LogFields.Field("expectedSha", expectedSha))); + if (fromBuffer) + { + ClaimBufferOwnership(repoRoot, posixPath); + } + return null; } @@ -331,15 +336,23 @@ private async Task WatchPresentationAsync(string repoRoot, str }; } - private async Task CurrentShaAsync(ReviewDocument document, string filePath) + private async Task<(string Sha, bool FromBuffer)> CurrentShaAsync(ReviewDocument document, string filePath) { - if (document.IsDirty) + var bytes = await _fileAccess.ReadFileBytesAsync(filePath).ConfigureAwait(false); + if (BufferDiffersFromDisk(document, bytes)) { - return GitBlobSha.FromUtf8(document.Content); + return (GitBlobSha.FromUtf8(document.Content), true); } - var bytes = await _fileAccess.ReadFileBytesAsync(filePath).ConfigureAwait(false); - return bytes != null ? GitBlobSha.FromBytes(bytes) : GitBlobSha.FromUtf8(document.Content); + return (GitBlobSha.FromBytes(bytes), false); + } + + private void ClaimBufferOwnership(string repoRoot, string relPath) + { + lock (_gate) + { + _bufferOwnedPaths.Add(PathKey(repoRoot, relPath)); + } } private bool AcceptsWatchRepoUnlocked(string repoRoot) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs index df9fddeb..958ae0ed 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Cli/ReviewPipeline.cs @@ -3,7 +3,9 @@ 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; @@ -246,6 +248,24 @@ 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); From 403013127e4161c87002e9fdbc30ccaf108660e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 21 Oct 2026 10:51:19 +0200 Subject: [PATCH 35/38] fix(watch): watch a repository nested one level under the opened folder A saved edit leaves the monitor to the CLI watch. When the git root sits below the opened folder, that watch never started, so the monitor stayed empty. --- ...orkspaceWatchCoordinatorNestedRepoTests.cs | 52 +++++++++++++++ .../Git/WorkspaceWatchCoordinator.cs | 5 ++ .../Util/GitPathDiscovery.cs | 65 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorNestedRepoTests.cs 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/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs index 2f3b8e41..83054357 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -153,6 +153,11 @@ private static List ResolveTargets(IReadOnlyList directories) var repoRoot = GitPathDiscovery.TryGetWorkingDirectory(directory); if (string.IsNullOrEmpty(repoRoot)) { + foreach (var nested in GitPathDiscovery.FindNestedRepositories(directory)) + { + targets[RpcPath.NormalizeFsPath(nested)] = nested; + } + continue; } diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Util/GitPathDiscovery.cs index 0d29d1ba..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,26 @@ 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)) @@ -80,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); + } } } From 015c6a061e77a4606cbd17d381b73b00b95ddefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Wed, 21 Oct 2026 14:57:45 +0200 Subject: [PATCH 36/38] test(watch): cover nested repository scan edge cases The 0.9.0 coverage gate rejected the nested-repo scan at 84.6%. These tests hit a missing directory, a hidden child, and a directory that cannot be listed. --- .../GitPathHelperTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/GitPathHelperTests.cs index 15da1aa2..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,49 @@ 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() { From 8118756442e63ba1ca44f55b1f201b53bb0a0aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Thu, 22 Oct 2026 10:54:06 +0200 Subject: [PATCH 37/38] fix(margin): refresh the editor score when a delta arrives Review often finishes first, so the margin stayed on the absolute score until the next edit. --- .../Adapters/VisualStudioReviewPipelinePresentation.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs index 9579d2cf..1081ec15 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Application/Adapters/VisualStudioReviewPipelinePresentation.cs @@ -105,6 +105,7 @@ public void PresentDelta(PresentedDelta delta) new DeltaCacheEntry(delta.Document.FilePath, string.Empty, string.Empty, delta.Result), _telemetryManager); _notifier.RequestViewUpdate(); + _marginSettings.NotifyScoreUpdated(); } public void Remove(ReviewDocument document) From e7668d320c22e1a7bbb5e06ffde77f81e192babb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20S=C3=A4fsten?= Date: Fri, 23 Oct 2026 14:05:45 +0200 Subject: [PATCH 38/38] fix(monitor): keep gitignored open files off the Code Health monitor Re-check gitignore on editor edits and exclude ignored dirty buffers from watch prune, seed, and delta admission. --- ...WorkspaceWatchCoordinatorGitIgnoreTests.cs | 138 ++++++++++++++++++ .../Git/WorkspaceWatchCoordinator.cs | 24 ++- .../EditorDocumentLifecycleHandler.cs | 24 +++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 Codescene.VSExtension.VS2022/Codescene.VSExtension.Core.Tests/WorkspaceWatchCoordinatorGitIgnoreTests.cs 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/Application/Git/WorkspaceWatchCoordinator.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs index 83054357..818aa6ca 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.Core/Application/Git/WorkspaceWatchCoordinator.cs @@ -27,6 +27,7 @@ public sealed partial class WorkspaceWatchCoordinator : IWorkspaceWatchCoordinat 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); @@ -44,12 +45,14 @@ public WorkspaceWatchCoordinator( IReviewPipeline pipeline, ILogger logger, [Import(AllowDefault = true)] IWorkspaceWatchHost host = null, - [Import(AllowDefault = true)] ISettingsProvider settings = 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; @@ -415,7 +418,10 @@ private void PruneMonitor() foreach (var document in _host?.GetDirtyDocuments() ?? Array.Empty()) { - keep.Add(document.FilePath); + if (!IsGitIgnoredDocument(document)) + { + keep.Add(document.FilePath); + } } _host?.PruneMonitor(repoRoots, keep); @@ -479,6 +485,11 @@ private List DirtySubmissions(string repoRoot) continue; } + if (IsGitIgnoredDocument(document)) + { + continue; + } + submissions.Add(new ReviewSubmission { Document = document, @@ -498,13 +509,20 @@ private bool IsDirty(string repoRoot, string relPath) { if (RpcPath.ToPosixRelPath(RpcPath.RelativePosix(repoRoot, document.FilePath)) == relPath) { - return true; + 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); diff --git a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs index 15c45c40..d0f895e6 100644 --- a/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs +++ b/Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/Handlers/EditorDocumentLifecycleHandler.cs @@ -103,6 +103,12 @@ public void TextViewCreated(IWpfTextView textView) return; } + if (_gitService.IsFileIgnored(filePath)) + { + RemoveGitIgnoredOpenFile(filePath); + return; + } + if (ShouldCheckAceStaleStatus(filePath)) { _scheduler.Schedule(async ct => @@ -169,6 +175,18 @@ private void OnLastViewClosed(string 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); @@ -284,6 +302,12 @@ private async Task ReviewContentAsync(string path, ITextBuffer buffer) return; } + if (_gitService.IsFileIgnored(path)) + { + RemoveGitIgnoredOpenFile(path); + return; + } + try { var code = buffer.CurrentSnapshot.GetText();