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 d3327eaeda..8475864706 100644 --- a/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs +++ b/Src/Common/RootSite/RootSiteTests/RealDataTestsBase.cs @@ -1,8 +1,10 @@ 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.IO; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Infrastructure; @@ -18,64 +20,93 @@ namespace SIL.FieldWorks.Common.RootSites.RootSiteTests [TestFixture] 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"; + 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 = "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); + AcquireProjectMutex(); - // Init New Lang Project Model (headless) - m_model = new FwNewLangProjectModel(true) + try { - LoadProjectNameSetup = () => { }, - LoadVernacularSetup = () => { }, - LoadAnalysisSetup = () => { }, - AnthroModel = new FwChooseAnthroListModel { CurrentList = FwChooseAnthroListModel.ListChoice.UserDef } - }; + DeleteProjectDirectory(m_projectDirectory); - 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_model = new FwNewLangProjectModel(true) + { + LoadProjectNameSetup = () => { }, + LoadVernacularSetup = () => { }, + LoadAnalysisSetup = () => { }, + AnthroModel = new FwChooseAnthroListModel + { + CurrentList = FwChooseAnthroListModel.ListChoice.UserDef, + }, + }; - // 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()); + 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); + } - try - { - using (var undoWatcher = new UndoableUnitOfWorkHelper(Cache.ActionHandlerAccessor, "Test Setup", "Undo Test Setup")) + Cache = LcmCache.CreateCacheFromExistingData( + new TestProjectId(BackendProviderType.kXMLWithMemoryOnlyWsMgr, createdPath), + "en", + new DummyLcmUI(), + FwDirectoryFinder.LcmDirectories, + new LcmSettings(), + new DummyProgressDlg() + ); + + 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; - } + RunSetupFailureCleanup( + TryDisposeCacheAfterSetupFailure, + TryDeleteProjectDirectoryAfterSetupFailure, + ReleaseProjectMutex + ); throw; } } @@ -93,21 +124,245 @@ protected virtual void CreateTestData() [TearDown] public virtual void TestTearDown() { - if (Cache != null) + try + { + DisposeCache(); + DeleteProjectDirectory(m_projectDirectory); + } + finally + { + m_projectDirectory = null; + ReleaseProjectMutex(); + } + } + + 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 TryDisposeCacheAfterSetupFailure() + { + if (Cache == null) + return; + + try + { + DisposeCache(); + } + catch (Exception e) { - Cache.Dispose(); Cache = null; + TestContext.Error.WriteLine( + "Could not dispose test cache after setup failure for '{0}': {1}", + m_dbName, + e.Message + ); } - var dbPath = DbFilename(m_dbName); - if (File.Exists(dbPath)) + } + + private void TryDeleteProjectDirectoryAfterSetupFailure() + { + try { - try { File.Delete(dbPath); } catch { } + 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 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." + ); + } + + 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; + + 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 + ) + ); + } + + if (!RobustIO.DeleteDirectoryAndContents(safeProjectDirectory)) + { + 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 + ) + ); + } + } + + 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); } - protected string DbFilename(string name) + private static string NormalizePath(string path) { - return Path.Combine(Path.GetTempPath(), name + ".fwdata"); + 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