From fa8341a59d3139aa00b6530a864970c45e3338e9 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 18 May 2026 17:47:10 -0400 Subject: [PATCH 1/2] Reuse integration test project data in RootSite tests --- .../RootSiteTests/RealDataTestsBase.cs | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs index d3327eaeda..6502acceec 100644 --- a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs +++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs @@ -18,17 +18,19 @@ namespace SIL.FieldWorks.Common.RootSites.RootSiteTests [TestFixture] public abstract class RealDataTestsBase { + private const string ReusableProjectName = "integration_test_data"; + protected FwNewLangProjectModel m_model; protected LcmCache Cache; protected string m_dbName; + private string m_projectDirectory; [SetUp] public virtual void TestSetup() { - m_dbName = "RealDataTest_" + Guid.NewGuid().ToString("N"); - var dbPath = DbFilename(m_dbName); - if (File.Exists(dbPath)) - File.Delete(dbPath); + m_dbName = ReusableProjectName; + m_projectDirectory = DbDirectory(m_dbName); + DeleteProjectDirectory(m_projectDirectory); // Init New Lang Project Model (headless) m_model = new FwNewLangProjectModel(true) @@ -48,6 +50,7 @@ public virtual void TestSetup() m_model.Next(); // To Analysis WS Setup m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "en", DesiredName = "English" }); createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper); + m_projectDirectory = Path.GetDirectoryName(createdPath); } // Load the cache from the newly created .fwdata file @@ -98,16 +101,22 @@ public virtual void TestTearDown() Cache.Dispose(); Cache = null; } - var dbPath = DbFilename(m_dbName); - if (File.Exists(dbPath)) - { - try { File.Delete(dbPath); } catch { } - } + + DeleteProjectDirectory(m_projectDirectory); + m_projectDirectory = null; } - protected string DbFilename(string name) + protected string DbDirectory(string name) { - return Path.Combine(Path.GetTempPath(), name + ".fwdata"); + return Path.Combine(FwDirectoryFinder.ProjectsDirectory, name); + } + + private static void DeleteProjectDirectory(string projectDirectory) + { + if (string.IsNullOrEmpty(projectDirectory) || !Directory.Exists(projectDirectory)) + return; + + try { Directory.Delete(projectDirectory, true); } catch { } } } } From 934821975d3d47b3a02daa596ce7585e1d344f2e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 18 May 2026 20:12:21 -0400 Subject: [PATCH 2/2] Harden RootSite integration test cleanup --- .../RootSiteTests/RealDataTestsBase.cs | 296 +++++++++++++++--- 1 file changed, 248 insertions(+), 48 deletions(-) diff --git a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs index 6502acceec..a09a35a211 100644 --- a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs +++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs @@ -1,8 +1,9 @@ using System; using System.IO; +using System.Threading; using NUnit.Framework; -using SIL.FieldWorks.FwCoreDlgs; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.FwCoreDlgs; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Infrastructure; @@ -19,66 +20,91 @@ namespace SIL.FieldWorks.Common.RootSites.RootSiteTests public abstract class RealDataTestsBase { private const string ReusableProjectName = "integration_test_data"; + private const string ProjectMutexName = @"Local\FieldWorks.RealDataTests.integration_test_data"; + private const string TestProjectSentinelFileName = ".fieldworks-real-data-test-project"; + private const int DeleteRetryCount = 3; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250); protected FwNewLangProjectModel m_model; protected LcmCache Cache; protected string m_dbName; private string m_projectDirectory; + private Mutex m_projectMutex; [SetUp] public virtual void TestSetup() { m_dbName = ReusableProjectName; m_projectDirectory = DbDirectory(m_dbName); - DeleteProjectDirectory(m_projectDirectory); + AcquireProjectMutex(); - // Init New Lang Project Model (headless) - m_model = new FwNewLangProjectModel(true) - { - LoadProjectNameSetup = () => { }, - LoadVernacularSetup = () => { }, - LoadAnalysisSetup = () => { }, - AnthroModel = new FwChooseAnthroListModel { CurrentList = FwChooseAnthroListModel.ListChoice.UserDef } - }; - - string createdPath; - using (var threadHelper = new ThreadHelper()) + try { - m_model.ProjectName = m_dbName; - m_model.Next(); // To Vernacular WS Setup - m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "qaa", DesiredName = "Vernacular" }); - m_model.Next(); // To Analysis WS Setup - m_model.SetDefaultWs(new LanguageInfo { LanguageTag = "en", DesiredName = "English" }); - createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper); - m_projectDirectory = Path.GetDirectoryName(createdPath); - } + DeleteProjectDirectory(m_projectDirectory); + + m_model = new FwNewLangProjectModel(true) + { + LoadProjectNameSetup = () => { }, + LoadVernacularSetup = () => { }, + LoadAnalysisSetup = () => { }, + AnthroModel = new FwChooseAnthroListModel + { + CurrentList = FwChooseAnthroListModel.ListChoice.UserDef, + }, + }; + + string createdPath; + using (var threadHelper = new ThreadHelper()) + { + m_model.ProjectName = m_dbName; + m_model.Next(); // To Vernacular WS Setup + m_model.SetDefaultWs( + new LanguageInfo { LanguageTag = "qaa", DesiredName = "Vernacular" } + ); + m_model.Next(); // To Analysis WS Setup + m_model.SetDefaultWs( + new LanguageInfo { LanguageTag = "en", DesiredName = "English" } + ); + createdPath = m_model.CreateNewLangProj(new DummyProgressDlg(), threadHelper); + m_projectDirectory = GetProjectDirectory(createdPath); + WriteTestProjectSentinel(m_projectDirectory); + } - // Load the cache from the newly created .fwdata file - Cache = LcmCache.CreateCacheFromExistingData( - new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath), - "en", - new DummyLcmUI(), - FwDirectoryFinder.LcmDirectories, - new LcmSettings(), - new DummyProgressDlg()); + Cache = LcmCache.CreateCacheFromExistingData( + new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath), + "en", + new DummyLcmUI(), + FwDirectoryFinder.LcmDirectories, + new LcmSettings(), + new DummyProgressDlg() + ); - try - { - using (var undoWatcher = new UndoableUnitOfWorkHelper(Cache.ActionHandlerAccessor, "Test Setup", "Undo Test Setup")) + try { - InitializeProjectData(); - CreateTestData(); - undoWatcher.RollBack = false; + using ( + var undoWatcher = new UndoableUnitOfWorkHelper( + Cache.ActionHandlerAccessor, + "Test Setup", + "Undo Test Setup" + ) + ) + { + InitializeProjectData(); + CreateTestData(); + undoWatcher.RollBack = false; + } + } + catch (Exception) + { + DisposeCache(); + throw; } } catch (Exception) { - // If setup fails, ensure we don't leave a locked DB - if (Cache != null) - { - Cache.Dispose(); - Cache = null; - } + DisposeCache(); + TryDeleteProjectDirectoryAfterSetupFailure(); + ReleaseProjectMutex(); throw; } } @@ -96,14 +122,16 @@ protected virtual void CreateTestData() [TearDown] public virtual void TestTearDown() { - if (Cache != null) + try { - Cache.Dispose(); - Cache = null; + DisposeCache(); + DeleteProjectDirectory(m_projectDirectory); + } + finally + { + m_projectDirectory = null; + ReleaseProjectMutex(); } - - DeleteProjectDirectory(m_projectDirectory); - m_projectDirectory = null; } protected string DbDirectory(string name) @@ -111,12 +139,184 @@ protected string DbDirectory(string name) return Path.Combine(FwDirectoryFinder.ProjectsDirectory, name); } + private void AcquireProjectMutex() + { + m_projectMutex = new Mutex(false, ProjectMutexName); + try + { + m_projectMutex.WaitOne(); + } + catch (AbandonedMutexException) + { + } + } + + private void ReleaseProjectMutex() + { + if (m_projectMutex == null) + return; + + try + { + m_projectMutex.ReleaseMutex(); + } + catch (ApplicationException) + { + } + finally + { + m_projectMutex.Dispose(); + m_projectMutex = null; + } + } + + private void DisposeCache() + { + if (Cache == null) + return; + + Cache.Dispose(); + Cache = null; + } + + private void TryDeleteProjectDirectoryAfterSetupFailure() + { + try + { + DeleteProjectDirectory(m_projectDirectory); + } + catch (Exception e) + { + TestContext.Error.WriteLine( + "Could not clean up test project directory '{0}' after setup failure: {1}", + m_projectDirectory, + e.Message + ); + } + } + + private static string GetProjectDirectory(string createdPath) + { + if (string.IsNullOrEmpty(createdPath)) + throw new InvalidOperationException("CreateNewLangProj did not return a project path."); + + var fullPath = NormalizePath(createdPath); + if (Directory.Exists(fullPath)) + { + EnsureSafeProjectDirectory(fullPath); + return fullPath; + } + + if (!File.Exists(fullPath)) + throw new FileNotFoundException("CreateNewLangProj returned a path that does not exist.", fullPath); + + var projectDirectory = Path.GetDirectoryName(fullPath); + EnsureSafeProjectDirectory(projectDirectory); + return projectDirectory; + } + + private static void WriteTestProjectSentinel(string projectDirectory) + { + EnsureSafeProjectDirectory(projectDirectory); + File.WriteAllText( + GetSentinelFilePath(projectDirectory), + "Created by FieldWorks RootSiteTests. This directory is safe for tests to delete." + ); + } + private static void DeleteProjectDirectory(string projectDirectory) { if (string.IsNullOrEmpty(projectDirectory) || !Directory.Exists(projectDirectory)) return; - try { Directory.Delete(projectDirectory, true); } catch { } + var safeProjectDirectory = NormalizePath(projectDirectory); + EnsureSafeProjectDirectory(safeProjectDirectory); + + if (!File.Exists(GetSentinelFilePath(safeProjectDirectory))) + { + throw new InvalidOperationException( + string.Format( + "Refusing to delete '{0}' because the test sentinel file '{1}' is missing.", + safeProjectDirectory, + TestProjectSentinelFileName + ) + ); + } + + Exception lastException = null; + for (var attempt = 1; attempt <= DeleteRetryCount; attempt++) + { + try + { + Directory.Delete(safeProjectDirectory, true); + return; + } + catch (IOException e) + { + lastException = e; + LogDeleteFailure(safeProjectDirectory, attempt, e); + } + catch (UnauthorizedAccessException e) + { + lastException = e; + LogDeleteFailure(safeProjectDirectory, attempt, e); + } + + if (attempt < DeleteRetryCount) + Thread.Sleep(DeleteRetryDelay); + } + + throw new IOException( + string.Format( + "Could not delete test project directory '{0}' after {1} attempts.", + safeProjectDirectory, + DeleteRetryCount + ), + lastException + ); + } + + private static void LogDeleteFailure(string projectDirectory, int attempt, Exception e) + { + TestContext.Error.WriteLine( + "Could not delete test project directory '{0}' on attempt {1} of {2}: {3}", + projectDirectory, + attempt, + DeleteRetryCount, + e.Message + ); + } + + private static void EnsureSafeProjectDirectory(string projectDirectory) + { + if (string.IsNullOrEmpty(projectDirectory)) + throw new InvalidOperationException("The test project directory path is empty."); + + var safeProjectDirectory = NormalizePath(projectDirectory); + var expectedProjectDirectory = NormalizePath( + Path.Combine(FwDirectoryFinder.ProjectsDirectory, ReusableProjectName) + ); + + if (!string.Equals(safeProjectDirectory, expectedProjectDirectory, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + string.Format( + "Refusing to use test project directory '{0}'; expected '{1}'.", + safeProjectDirectory, + expectedProjectDirectory + ) + ); + } + } + + private static string GetSentinelFilePath(string projectDirectory) + { + return Path.Combine(projectDirectory, TestProjectSentinelFileName); + } + + private static string NormalizePath(string path) + { + return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } } }