From c4d70fc3ded31e2935355c0f7b2e537076cb8e48 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 22 May 2026 07:26:56 -0400 Subject: [PATCH 1/3] 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 f5fed921df1e9f0851b22ed6d0b8184e53457e1e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 22 May 2026 07:26:56 -0400 Subject: [PATCH 2/3] 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); } } } From 9dab396632d30d987441587d737cd8a8c3201883 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 22 May 2026 08:38:31 -0400 Subject: [PATCH 3/3] Respond to review and add SIL library skill --- .github/skills/sil-library-reuse/SKILL.md | 349 ++++++++++++++++++ .../RootSiteTests/RealDataTestsBase.cs | 158 +++++--- .../RealDataTestsBaseCleanupTests.cs | 64 ++++ 3 files changed, 515 insertions(+), 56 deletions(-) create mode 100644 .github/skills/sil-library-reuse/SKILL.md create mode 100644 Src/Common/RootSite/RootSiteTests/RealDataTestsBaseCleanupTests.cs diff --git a/.github/skills/sil-library-reuse/SKILL.md b/.github/skills/sil-library-reuse/SKILL.md new file mode 100644 index 0000000000..17a5c600b4 --- /dev/null +++ b/.github/skills/sil-library-reuse/SKILL.md @@ -0,0 +1,349 @@ +--- +name: sil-library-reuse +description: > + Reuse lower-level SIL libraries before writing custom helpers in FieldWorks. + Use this whenever a task touches file or directory I/O, locked-file retries, + paths or URIs, writing systems, keyboards, LCM caches/project IDs/service + locators, linked files/media/config paths, localization, LIFT/import/export, + FLExBridge or Chorus sync/merge flows, analytics/telemetry, morphology or + NLP, scripture/Paratext integration, or SIL test helpers. Even for small + fixes, check the package families and repo patterns here first. If any + guidance here proves inaccurate or stale, revalidate against current + FieldWorks package references, local package metadata, and upstream repos, + then update this skill so it stays current. +--- + +# SIL Library Reuse + +FieldWorks already depends on SIL packages that solve many of the "small +helper" problems agents are tempted to rebuild. Start here before adding new +filesystem wrappers, cache bootstraps, writing-system plumbing, localization +hooks, bridge calls, or parser code. + +## Staying Current + +If you find this to be inaccurate, treat that as a maintenance signal, not a +reason to ignore the skill. SIL package versions, upstream repo layouts, and +public API names change over time. Revalidate the claim against the current +FieldWorks package references, installed package metadata, local usage, and +upstream repos, then update this skill in the same change when practical. If +you cannot update it, call out the stale item clearly in your final response. + +## Workflow + +1. Identify the package family from the touched namespace or project file. If + the package or version is unclear, check + [Directory.Packages.props](../../../Directory.Packages.props) and + [Build/SilVersions.props](../../../Build/SilVersions.props). +2. Search FieldWorks first for the concrete helper or namespace and copy the + existing local pattern. +3. If local usage is thin or the name is unfamiliar, open the upstream repo + links below. Do not assume Context7 covers these packages well. +4. Prefer the library helper over custom code when it already handles retries, + path normalization, cache setup, localization metadata, sync boundaries, or + corpus parsing. +5. Keep FieldWorks constraints intact: .NET Framework 4.8, C# 7.3, `.resx` for + user-facing strings, registration-free COM, and repo build/test scripts. + +## Good First Searches + +- `RobustIO|RobustFile|RetryUtility|Keyboard.Controller|FileLocationUtilities` +- `LcmCache|TestProjectId|SimpleProjectId|ServiceLocator.GetInstance|LcmFileHelper|WritingSystemManager|FileUtils` +- `LocalizationManagerWinforms|L10NExtender` +- `Analytics.Track|Analytics.ReportException|FLExBridgeHelper` +- `Paratext|IParatextHelper|Usfm|Usx|Morpher|SIL.Machine` + +## Package Family Map + +### `libpalaso` + +Packages: +`SIL.Core`, `SIL.Core.Desktop`, `SIL.Archiving`, `SIL.Lexicon`, `SIL.Lift`, +`SIL.Media`, `SIL.Scripture`, `SIL.TestUtilities`, `SIL.Windows.Forms*`, +`SIL.WritingSystems` + +Use for: +filesystem resilience, path helpers, retry loops, desktop file helpers, +writing systems, keyboards, LIFT processing, archiving, and test temp-file +helpers. + +FieldWorks usage anchors: + +- [Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs](../../../Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs) +- [Src/xWorks/FwXWindow.cs](../../../Src/xWorks/FwXWindow.cs) +- [Src/Common/SimpleRootSite/EditingHelper.cs](../../../Src/Common/SimpleRootSite/EditingHelper.cs) + +Main APIs to look for first: + +- `RobustIO.DeleteDirectoryAndContents` +- `RobustIO.MoveDirectory` +- `RobustIO.RequireThatDirectoryExists` +- `RobustFile.Copy` +- `RobustFile.WriteAllBytes` +- `RobustFile.ReplaceByCopyDelete` +- `RetryUtility.Retry` +- `PathHelper.NormalizePath` +- `PathHelper.AreOnSameVolume` +- `PathHelper.StripFilePrefix` +- `FileLocationUtilities.LocateExecutable` +- `FileLocationUtilities.LocateInProgramFiles` +- `PathUtilities.SelectFileInExplorer` +- `PathUtilities.OpenDirectoryInExplorer` +- `WritingSystemDefinition` +- `IWritingSystemRepository` +- `Keyboard.Controller` +- `KeyboardController` +- `LiftSorter.SortLiftFile` +- `LiftPreparer.MigrateLiftFile` +- `WritingSystemsInLiftFileHelper` +- `ArchivingFileSystem` + +Upstream repo: + +- [libpalaso](https://github.com/sillsdev/libpalaso) +- [SIL.Core/IO](https://github.com/sillsdev/libpalaso/tree/master/SIL.Core/IO) +- [SIL.Core.Desktop/IO](https://github.com/sillsdev/libpalaso/tree/master/SIL.Core.Desktop/IO) +- [SIL.WritingSystems](https://github.com/sillsdev/libpalaso/tree/master/SIL.WritingSystems) +- [SIL.Windows.Forms.Keyboarding](https://github.com/sillsdev/libpalaso/tree/master/SIL.Windows.Forms.Keyboarding) +- [SIL.Windows.Forms.WritingSystems](https://github.com/sillsdev/libpalaso/tree/master/SIL.Windows.Forms.WritingSystems) +- [SIL.Lift](https://github.com/sillsdev/libpalaso/tree/master/SIL.Lift) +- [SIL.Archiving](https://github.com/sillsdev/libpalaso/tree/master/SIL.Archiving) +- [SIL.TestUtilities](https://github.com/sillsdev/libpalaso/tree/master/SIL.TestUtilities) + +### `liblcm` + +Packages: +`SIL.LCModel`, `SIL.LCModel.Core`, `SIL.LCModel.Utils`, +`SIL.LCModel.FixData`, `SIL.LCModel.Tests` + +Use for: +LCM cache creation, project IDs, service location, writing-system managers, +linked/media/config paths, imports, migrations, and repository/factory access. + +FieldWorks usage anchors: + +- [Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs](../../../Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs) +- [Src/xWorks/FwXWindow.cs](../../../Src/xWorks/FwXWindow.cs) +- [Src/xWorks/xWorksTests/ExportDialogTests.cs](../../../Src/xWorks/xWorksTests/ExportDialogTests.cs) + +Main APIs to look for first: + +- `LcmCache.CreateCacheFromExistingData` +- `LcmCache.CreateCacheWithNewBlankLangProj` +- `ILcmServiceLocator` +- `ServiceLocator.GetInstance()` +- `IProjectIdentifier` +- `SimpleProjectId` +- `TestProjectId` +- `LcmFileHelper.GetConfigSettingsDir` +- `LcmFileHelper.GetDefaultLinkedFilesDir` +- `LcmFileHelper.GetDefaultMediaDir` +- `LcmFileHelper.GetWritingSystemDir` +- `LinkedFilesRelativePathHelper` +- `FileUtils.FileExists` +- `FileUtils.DirectoryExists` +- `FileUtils.EnsureDirectoryExists` +- `FileUtils.StripFilePrefix` +- `FileUtils.ChangePathToPlatform` +- `WritingSystemManager` +- `UnitOfWorkService` + +Upstream repo: + +- [liblcm](https://github.com/sillsdev/liblcm) +- [src/SIL.LCModel](https://github.com/sillsdev/liblcm/tree/master/src/SIL.LCModel) +- [Infrastructure](https://github.com/sillsdev/liblcm/tree/master/src/SIL.LCModel/Infrastructure) +- [DomainServices](https://github.com/sillsdev/liblcm/tree/master/src/SIL.LCModel/DomainServices) +- [src/SIL.LCModel.Core](https://github.com/sillsdev/liblcm/tree/master/src/SIL.LCModel.Core) +- [src/SIL.LCModel.Utils](https://github.com/sillsdev/liblcm/tree/master/src/SIL.LCModel.Utils) + +### `l10nsharp` + +Packages: +`L10NSharp`, `L10NSharp.Windows.Forms` + +Use for: +runtime WinForms localization, XLIFF-backed string management, control +metadata, and language switching. + +FieldWorks usage anchors: + +- [Src/Common/FieldWorks/FieldWorks.cs](../../../Src/Common/FieldWorks/FieldWorks.cs) + +Main APIs to look for first: + +- `LocalizationManagerWinforms.Create` +- `LocalizationManagerWinforms.SetUILanguage` +- `LocalizationManagerWinforms.ReapplyLocalizationsToAllObjects` +- `L10NExtender` +- `ILocalizableComponent` +- `LocalizingInfoWinforms` +- `LocalizationCategory` +- `XliffLocalizationManagerWinforms` + +Upstream repo: + +- [l10nsharp](https://github.com/sillsdev/l10nsharp) +- [src/L10NSharp](https://github.com/sillsdev/l10nsharp/tree/master/src/L10NSharp) +- [src/L10NSharp.Windows.Forms](https://github.com/sillsdev/l10nsharp/tree/master/src/L10NSharp.Windows.Forms) +- [UIComponents](https://github.com/sillsdev/l10nsharp/tree/master/src/L10NSharp.Windows.Forms/UIComponents) +- [SampleApp](https://github.com/sillsdev/l10nsharp/tree/master/src/SampleApp) + +### `desktopanalytics.net` + +Packages: +`SIL.DesktopAnalytics` + +Use for: +telemetry, consent-aware tracking, exception reporting, and application-level +analytics properties. + +FieldWorks usage anchors: + +- [Src/Common/FieldWorks/FieldWorks.cs](../../../Src/Common/FieldWorks/FieldWorks.cs) +- [Src/Common/FwUtils/TrackingHelper.cs](../../../Src/Common/FwUtils/TrackingHelper.cs) +- [Src/LexText/LexTextDll/AreaListener.cs](../../../Src/LexText/LexTextDll/AreaListener.cs) + +Main APIs to look for first: + +- `Analytics.Track` +- `Analytics.ReportException` +- `Analytics.AllowTracking` +- `Analytics.IdentifyUpdate` +- `Analytics.SetApplicationProperty` +- `Analytics.FlushClient` +- `UserInfo.CreateSanitized` + +Upstream repo: + +- [DesktopAnalytics.net](https://github.com/sillsdev/DesktopAnalytics.net) +- [Analytics.cs](https://github.com/sillsdev/DesktopAnalytics.net/blob/master/src/DesktopAnalytics/Analytics.cs) +- [UserInfo.cs](https://github.com/sillsdev/DesktopAnalytics.net/blob/master/src/DesktopAnalytics/UserInfo.cs) + +### `ipcframework` and `chorus` + +Packages: +`SIL.FLExBridge.IPCFramework`, `SIL.Chorus.App`, `SIL.Chorus.LibChorus`, +`SIL.Chorus.L10ns` + +Use for: +FLExBridge launch/obtain/send-receive flows, IPC contracts, sync/merge +orchestration, file-type handlers, and merge/conflict notes. + +FieldWorks usage anchors: + +- [Src/Common/FwUtils/FLExBridgeHelper.cs](../../../Src/Common/FwUtils/FLExBridgeHelper.cs) +- [Src/LexText/Lexicon/FLExBridgeListener.cs](../../../Src/LexText/Lexicon/FLExBridgeListener.cs) +- [Src/Common/Controls/FwControls/ObtainProjectMethod.cs](../../../Src/Common/Controls/FwControls/ObtainProjectMethod.cs) + +Start with the local wrapper first: + +- `FLExBridgeHelper.LaunchFieldworksBridge` +- `FLExBridgeHelper.DoesProjectHaveFlexRepo` +- `FLExBridgeHelper.DoesProjectHaveLiftRepo` +- `FLExBridgeHelper.IsFlexBridgeInstalled` +- `FLExBridgeHelper.FullFieldWorksBridgePath` + +Then inspect upstream helpers if the change crosses the boundary: + +- `IPCClientFactory.Create` +- `IPCHostFactory.Create` +- `IIPCClient.RemoteCall` +- `IIPCHost.Initialize` +- `Synchronizer` +- `XmlMergeService.Do3WayMerge` +- `MergeOrder` +- `ElementStrategy` +- `IChorusFileTypeHandler` +- `ChorusNotesMergeEventListener` + +Upstream repos: + +- [ipcframework](https://github.com/sillsdev/ipcframework) +- [IPCFramework/IPCInterfaces.cs](https://github.com/sillsdev/ipcframework/tree/master/IPCFramework/IPCInterfaces.cs) +- [IPCClientFactory.cs](https://github.com/sillsdev/ipcframework/tree/master/IPCFramework/IPCClientFactory.cs) +- [IPCHostFactory.cs](https://github.com/sillsdev/ipcframework/tree/master/IPCFramework/IPCHostFactory.cs) +- [ServerProgram/FLExBridgeHelper.cs](https://github.com/sillsdev/ipcframework/tree/master/ServerProgram/FLExBridgeHelper.cs) +- [ClientProgram/FLExConnectionHelper.cs](https://github.com/sillsdev/ipcframework/tree/master/ClientProgram/FLExConnectionHelper.cs) +- [chorus](https://github.com/sillsdev/chorus) +- [src/LibChorus/sync](https://github.com/sillsdev/chorus/tree/master/src/LibChorus/sync) +- [src/LibChorus/merge/xml/generic](https://github.com/sillsdev/chorus/tree/master/src/LibChorus/merge/xml/generic) +- [src/LibChorus/FileTypeHandlers](https://github.com/sillsdev/chorus/tree/master/src/LibChorus/FileTypeHandlers) +- [src/LibChorus/VcsDrivers/Mercurial](https://github.com/sillsdev/chorus/tree/master/src/LibChorus/VcsDrivers/Mercurial) +- [src/LibChorus/notes](https://github.com/sillsdev/chorus/tree/master/src/LibChorus/notes) + +### `machine` + +Packages: +`SIL.Machine`, `SIL.Machine.Morphology.HermitCrab` + +Use for: +morphology, feature-structure-based NLP, text corpora, USFM/USX parsing, and +alignment. Direct FieldWorks usage is light today; consult this before +inventing parser or analysis code. + +Main APIs to look for first: + +- `Morpher.ParseWord` +- `Morpher.GenerateWords` +- `XmlLanguageLoader` +- `TraceManager` +- `FeatureStruct` +- `ParatextTextCorpus` +- `UsxFileTextCorpus` +- `UsfmParser` +- `CorporaUtils` + +Upstream repo: + +- [machine](https://github.com/sillsdev/machine) +- [src/SIL.Machine](https://github.com/sillsdev/machine/tree/master/src/SIL.Machine) +- [src/SIL.Machine.Morphology.HermitCrab](https://github.com/sillsdev/machine/tree/master/src/SIL.Machine.Morphology.HermitCrab) +- [src/SIL.Machine.Translation.Thot](https://github.com/sillsdev/machine/tree/master/src/SIL.Machine.Translation.Thot) + +### `Paratext` + +Packages: +`SIL.ParatextShared`, `ParatextData` + +Use for: +Scripture project access, verse references, plugin integration, USFM tokens, +and Paratext interoperability. + +FieldWorks usage anchors: + +- [Src/Common/ScriptureUtils/ScriptureUtilsTests/ParatextHelperTests.cs](../../../Src/Common/ScriptureUtils/ScriptureUtilsTests/ParatextHelperTests.cs) +- `Paratext8Plugin` projects and helpers + +Important caveat: +`SIL.ParatextShared` source for the legacy package used by FieldWorks is not +publicly available. Use the public docs and demo plugins instead of hunting +for a missing source tree. + +Public docs and references: + +- [Paratext demo plugins](https://github.com/ubsicap/paratext_demo_plugins) +- [Full API documentation wiki](https://github.com/ubsicap/paratext_demo_plugins/wiki/Full-API-Documentation) +- [SIL.ParatextShared 7.4.0.1](https://www.nuget.org/packages/SIL.ParatextShared/7.4.0.1) + +Useful API names in the public docs: + +- `IProject` +- `IReadOnlyProject` +- `IVerseRef` +- `IUSFMToken` +- `IUSFMMarkerToken` +- `IScriptureTextSelection` +- `IBiblicalTerm` +- `IProjectNote` + +## Biases To Keep + +- Prefer `RobustIO` or `RobustFile` over ad hoc delete/retry loops. +- Prefer `LcmCache`, `TestProjectId`, `ServiceLocator.GetInstance()`, and + `LcmFileHelper` over custom LCM bootstrapping or path math. +- Prefer `LocalizationManagerWinforms` over ad hoc runtime localization state. +- Prefer `FLExBridgeHelper` over direct IPC calls from new UI code. +- Prefer existing analytics calls or wrappers over one-off telemetry clients. +- When a repo search comes up empty, switch from keyword search to + symbol/reference navigation before deciding the helper does not exist. \ No newline at end of file diff --git a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs index a09a35a211..8475864706 100644 --- a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs +++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.FwCoreDlgs; +using SIL.IO; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Infrastructure; @@ -20,10 +21,9 @@ 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 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; @@ -102,9 +102,11 @@ public virtual void TestSetup() } catch (Exception) { - DisposeCache(); - TryDeleteProjectDirectoryAfterSetupFailure(); - ReleaseProjectMutex(); + RunSetupFailureCleanup( + TryDisposeCacheAfterSetupFailure, + TryDeleteProjectDirectoryAfterSetupFailure, + ReleaseProjectMutex + ); throw; } } @@ -146,9 +148,7 @@ private void AcquireProjectMutex() { m_projectMutex.WaitOne(); } - catch (AbandonedMutexException) - { - } + catch (AbandonedMutexException) { } } private void ReleaseProjectMutex() @@ -160,9 +160,7 @@ private void ReleaseProjectMutex() { m_projectMutex.ReleaseMutex(); } - catch (ApplicationException) - { - } + catch (ApplicationException) { } finally { m_projectMutex.Dispose(); @@ -179,6 +177,26 @@ private void DisposeCache() Cache = null; } + private void TryDisposeCacheAfterSetupFailure() + { + if (Cache == null) + return; + + try + { + DisposeCache(); + } + catch (Exception e) + { + Cache = null; + TestContext.Error.WriteLine( + "Could not dispose test cache after setup failure for '{0}': {1}", + m_dbName, + e.Message + ); + } + } + private void TryDeleteProjectDirectoryAfterSetupFailure() { try @@ -195,10 +213,55 @@ private void TryDeleteProjectDirectoryAfterSetupFailure() } } + private static void RunSetupFailureCleanup( + Action disposeCache, + Action deleteProjectDirectory, + Action releaseProjectMutex + ) + { + Exception firstException = null; + + try + { + disposeCache(); + } + catch (Exception e) + { + firstException = e; + } + + try + { + deleteProjectDirectory(); + } + catch (Exception e) + { + if (firstException == null) + firstException = e; + } + + try + { + releaseProjectMutex(); + } + catch (Exception e) + { + if (firstException == null) + firstException = e; + } + + if (firstException != null) + throw firstException; + } + private static string GetProjectDirectory(string createdPath) { if (string.IsNullOrEmpty(createdPath)) - throw new InvalidOperationException("CreateNewLangProj did not return a project path."); + { + throw new InvalidOperationException( + "CreateNewLangProj did not return a project path." + ); + } var fullPath = NormalizePath(createdPath); if (Directory.Exists(fullPath)) @@ -208,7 +271,12 @@ private static string GetProjectDirectory(string createdPath) } if (!File.Exists(fullPath)) - throw new FileNotFoundException("CreateNewLangProj returned a path that does not exist.", fullPath); + { + throw new FileNotFoundException( + "CreateNewLangProj returned a path that does not exist.", + fullPath + ); + } var projectDirectory = Path.GetDirectoryName(fullPath); EnsureSafeProjectDirectory(projectDirectory); @@ -243,48 +311,19 @@ private static void DeleteProjectDirectory(string projectDirectory) ); } - Exception lastException = null; - for (var attempt = 1; attempt <= DeleteRetryCount; attempt++) + if (!RobustIO.DeleteDirectoryAndContents(safeProjectDirectory)) { - 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); + TestContext.Error.WriteLine( + "Could not delete test project directory '{0}' via RobustIO.DeleteDirectoryAndContents.", + safeProjectDirectory + ); + throw new IOException( + string.Format( + "Could not delete test project directory '{0}'.", + safeProjectDirectory + ) + ); } - - 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) @@ -297,7 +336,13 @@ private static void EnsureSafeProjectDirectory(string projectDirectory) Path.Combine(FwDirectoryFinder.ProjectsDirectory, ReusableProjectName) ); - if (!string.Equals(safeProjectDirectory, expectedProjectDirectory, StringComparison.OrdinalIgnoreCase)) + if ( + !string.Equals( + safeProjectDirectory, + expectedProjectDirectory, + StringComparison.OrdinalIgnoreCase + ) + ) { throw new InvalidOperationException( string.Format( @@ -316,7 +361,8 @@ private static string GetSentinelFilePath(string projectDirectory) private static string NormalizePath(string path) { - return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } } } diff --git a/Src/Common/RootSite/RootSiteTests/RealDataTestsBaseCleanupTests.cs b/Src/Common/RootSite/RootSiteTests/RealDataTestsBaseCleanupTests.cs new file mode 100644 index 0000000000..be97bca107 --- /dev/null +++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBaseCleanupTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Reflection; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.RootSites.RootSiteTests +{ + [TestFixture] + public class RealDataTestsBaseCleanupTests + { + [Test] + public void RunSetupFailureCleanup_ReleasesMutexEvenWhenDisposeFails() + { + var deleteCalled = false; + var releaseCalled = false; + var method = GetRunSetupFailureCleanupMethod(); + + var exception = Assert.Catch( + () => InvokeRunSetupFailureCleanup( + method, + () => { throw new InvalidOperationException("dispose failed"); }, + () => deleteCalled = true, + () => releaseCalled = true + ) + ); + + Assert.That(exception.Message, Is.EqualTo("dispose failed")); + Assert.That(deleteCalled, Is.True, "delete cleanup should still run"); + Assert.That(releaseCalled, Is.True, "mutex release should be guaranteed"); + } + + private static MethodInfo GetRunSetupFailureCleanupMethod() + { + var method = typeof(RealDataTestsBase).GetMethod( + "RunSetupFailureCleanup", + BindingFlags.Static | BindingFlags.NonPublic + ); + + Assert.That( + method, + Is.Not.Null, + "Expected RealDataTestsBase to expose a setup-failure cleanup helper." + ); + + return method; + } + + private static void InvokeRunSetupFailureCleanup( + MethodInfo method, + Action disposeCache, + Action deleteProjectDirectory, + Action releaseProjectMutex + ) + { + try + { + method.Invoke(null, new object[] { disposeCache, deleteProjectDirectory, releaseProjectMutex }); + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + throw e.InnerException; + } + } + } +} \ No newline at end of file