diff --git a/gclo.Engine.Tests/ActionLoggingTests.cs b/gclo.Engine.Tests/ActionLoggingTests.cs index af269af..9cd02cb 100644 --- a/gclo.Engine.Tests/ActionLoggingTests.cs +++ b/gclo.Engine.Tests/ActionLoggingTests.cs @@ -37,9 +37,23 @@ public sealed class ActionLoggingTests : IDisposable + /// + /// Synchronous progress: the handler runs inline on the reporting thread, one + /// report at a time. The view model's handler belongs to the UI thread and is + /// never entered twice at once there; several workers reporting at the same + /// moment must not enter it together here either. + /// private sealed class SyncProgress(Action handler) : IProgress { - public void Report(RepoProgress value) => handler(value); + private readonly Lock _oneAtATime = new(); + + public void Report(RepoProgress value) + { + lock(_oneAtATime) + { + handler(value); + } + } } diff --git a/gclo.Engine.Tests/AppSettingsTests.cs b/gclo.Engine.Tests/AppSettingsTests.cs index 012ab3d..fe7eb1e 100644 --- a/gclo.Engine.Tests/AppSettingsTests.cs +++ b/gclo.Engine.Tests/AppSettingsTests.cs @@ -10,7 +10,6 @@ namespace gclo.Engine.Tests; /// Pins defaults and load-time sanitizing of . -[Collection("GCLO_DATA_DIR")] // sets a process-wide variable: never beside another class that does public sealed class AppSettingsTests { [Fact] @@ -30,7 +29,6 @@ public sealed class AppSettingsTests // A pre-splash settings.json has no SplashMilliseconds field; it must load // as the 5s default, not as 0 clamped to the minimum. string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); try { Directory.CreateDirectory(dir); @@ -38,7 +36,7 @@ public sealed class AppSettingsTests Path.Combine(dir, "settings.json"), """{ "DefaultTargetFolder": "C:\\src", "DefaultMaxConcurrency": 8, "Theme": "Dark" }"""); - var loaded = AppSettings.Load(); + var loaded = AppSettings.Load(directory: dir); Assert.Equal(AppSettings.DefaultSplashMilliseconds, loaded.SplashMilliseconds); Assert.True(loaded.ShowSplashScreen); @@ -46,7 +44,6 @@ public sealed class AppSettingsTests } finally { - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); try { Directory.Delete(dir, recursive: true); @@ -63,25 +60,23 @@ public sealed class AppSettingsTests public void Save_ClampsSplashDuration() { string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); try { var settings = new AppSettings { SplashMilliseconds = 999_999 }; - settings.Save(); + settings.Save(directory: dir); Assert.Equal(AppSettings.MaxSplashMilliseconds, settings.SplashMilliseconds); settings.SplashMilliseconds = 1; - settings.Save(); + settings.Save(directory: dir); Assert.Equal(AppSettings.MinSplashMilliseconds, settings.SplashMilliseconds); // Round trip: the persisted values load back as saved. - var loaded = AppSettings.Load(); + var loaded = AppSettings.Load(directory: dir); Assert.Equal(AppSettings.MinSplashMilliseconds, loaded.SplashMilliseconds); Assert.True(loaded.ShowSplashScreen); } finally { - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); try { Directory.Delete(dir, recursive: true); @@ -100,14 +95,12 @@ public sealed class AppSettingsTests { string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); try { test(dir); } finally { - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); GitTestHelpers.TryDeleteDirectory(dir); } } @@ -120,11 +113,11 @@ public sealed class AppSettingsTests { var log = new RecordingActivityLog(); - bool saved = new AppSettings { Theme = "Dark" }.Save(log); + bool saved = new AppSettings { Theme = "Dark" }.Save(log, dir); Assert.True(saved); Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal); - Assert.Equal("Dark", AppSettings.Load(log).Theme); + Assert.Equal("Dark", AppSettings.Load(log, dir).Theme); Assert.Empty(log.Entries); }); @@ -139,20 +132,20 @@ public sealed class AppSettingsTests return; // elsewhere a file that is open can still be replaced } var log = new RecordingActivityLog(); - Assert.True(new AppSettings { Theme = "Dark" }.Save()); + Assert.True(new AppSettings { Theme = "Dark" }.Save(directory: dir)); string path = Path.Combine(dir, "settings.json"); bool saved; using(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) { - saved = new AppSettings { Theme = "Light" }.Save(log, TimeSpan.FromMilliseconds(100)); + saved = new AppSettings { Theme = "Light" }.Save(log, TimeSpan.FromMilliseconds(100), dir); } Assert.False(saved); (string level, string message) = Assert.Single(log.Entries); Assert.Equal("ERROR", level); Assert.StartsWith("Settings could not be saved (", message); - Assert.Equal("Dark", AppSettings.Load().Theme); // not half a file, not the new one: the old one + Assert.Equal("Dark", AppSettings.Load(directory: dir).Theme); // not half a file, not the new one: the old one Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal); }); @@ -167,24 +160,22 @@ public sealed class AppSettingsTests } string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); try { - Assert.True(new AppSettings { Theme = "Dark" }.Save()); + Assert.True(new AppSettings { Theme = "Dark" }.Save(directory: dir)); Task save; using(new FileStream(Path.Combine(dir, "settings.json"), FileMode.Open, FileAccess.Read, FileShare.Read)) { - save = Task.Run(() => new AppSettings { Theme = "Light" }.Save(log: null, TimeSpan.FromSeconds(60))); + save = Task.Run(() => new AppSettings { Theme = "Light" }.Save(log: null, TimeSpan.FromSeconds(60), dir)); Task finished = await Task.WhenAny(save, Task.Delay(TimeSpan.FromMilliseconds(200))); Assert.NotSame(save, finished); // still trying } Assert.True(await save.WaitAsync(TimeSpan.FromSeconds(60))); - Assert.Equal("Light", AppSettings.Load().Theme); + Assert.Equal("Light", AppSettings.Load(directory: dir).Theme); } finally { - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); GitTestHelpers.TryDeleteDirectory(dir); } } @@ -195,9 +186,9 @@ public sealed class AppSettingsTests public void Save_ByTwoAtOnce_NeverLeavesHalfAFile_OrALeftover() => WithDataDirectory(dir => { - Parallel.For(0, 40, i => new AppSettings { Theme = i % 2 == 0 ? "Dark" : "Light" }.Save()); + Parallel.For(0, 40, i => new AppSettings { Theme = i % 2 == 0 ? "Dark" : "Light" }.Save(directory: dir)); - Assert.Contains(AppSettings.Load().Theme, new[] { "Dark", "Light" }, StringComparer.Ordinal); + Assert.Contains(AppSettings.Load(directory: dir).Theme, new[] { "Dark", "Light" }, StringComparer.Ordinal); Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal); }); @@ -210,7 +201,7 @@ public sealed class AppSettingsTests var log = new RecordingActivityLog(); File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json"); - var loaded = AppSettings.Load(log); + var loaded = AppSettings.Load(log, dir); Assert.Equal("System", loaded.Theme); (string level, string message) = Assert.Single(log.Entries); @@ -226,6 +217,6 @@ public sealed class AppSettingsTests { File.WriteAllText(Path.Combine(dir, "settings.json"), "{ \"SplashMilliseconds\": 0 }"); - Assert.Equal(AppSettings.MinSplashMilliseconds, AppSettings.Load().SplashMilliseconds); + Assert.Equal(AppSettings.MinSplashMilliseconds, AppSettings.Load(directory: dir).SplashMilliseconds); }); } diff --git a/gclo.Engine.Tests/CoverageResidueTests.cs b/gclo.Engine.Tests/CoverageResidueTests.cs index 65fd395..e86724e 100644 --- a/gclo.Engine.Tests/CoverageResidueTests.cs +++ b/gclo.Engine.Tests/CoverageResidueTests.cs @@ -14,7 +14,6 @@ namespace gclo.Engine.Tests; /// branches, settings failure fallbacks, store corruption handling, and the /// remaining status-text arms. Part of the 100%-coverage contract. /// -[Collection("GCLO_DATA_DIR")] // sets a process-wide variable: never beside another class that does public sealed class CoverageResidueTests { // ------------------------------------------------- WindowsPathValidator @@ -55,7 +54,6 @@ public sealed class CoverageResidueTests { string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); return (dir, new EnvScope(dir)); } @@ -65,7 +63,6 @@ public sealed class CoverageResidueTests { public void Dispose() { - Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); try { Directory.Delete(dir, recursive: true); @@ -86,7 +83,7 @@ public sealed class CoverageResidueTests { File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json"); - var settings = AppSettings.Load(); + var settings = AppSettings.Load(directory: dir); Assert.Equal("System", settings.Theme); Assert.Equal(AppSettings.DefaultConcurrency, settings.DefaultMaxConcurrency); @@ -103,7 +100,7 @@ public sealed class CoverageResidueTests { File.WriteAllText(Path.Combine(dir, "settings.json"), "null"); - Assert.Equal("System", AppSettings.Load().Theme); + Assert.Equal("System", AppSettings.Load(directory: dir).Theme); } } @@ -118,7 +115,7 @@ public sealed class CoverageResidueTests File.WriteAllText( Path.Combine(dir, "settings.json"), """{ "Theme": "Hotdog" }"""); - Assert.Equal("System", AppSettings.Load().Theme); + Assert.Equal("System", AppSettings.Load(directory: dir).Theme); } } @@ -134,7 +131,10 @@ public sealed class CoverageResidueTests using var exclusiveLock = new FileStream( path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - new AppSettings().Save(); // must not throw + // Must not throw, and must say that nothing was written. + bool saved = new AppSettings().Save(log: null, TimeSpan.FromMilliseconds(50), dir); + + Assert.False(saved); } } diff --git a/gclo.Engine.Tests/GcloPathsTests.cs b/gclo.Engine.Tests/GcloPathsTests.cs index a9af527..38fd8b1 100644 --- a/gclo.Engine.Tests/GcloPathsTests.cs +++ b/gclo.Engine.Tests/GcloPathsTests.cs @@ -77,4 +77,29 @@ public sealed class GcloPathsTests Assert.Equal(Path.Combine(GcloPaths.DefaultDataRoot, "logs"), GcloPaths.LogDirectory); } + + + + /// + /// Settings named no folder go to the data folder. Checked here, in the one class + /// that moves the data folder for the process, so no settings test has to. + /// + [Fact] + public void Settings_SavedAndLoadedWithoutAFolder_UseTheDataRoot() + { + string moved = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable("GCLO_DATA_DIR", moved); + try + { + Assert.True(new AppSettings { Theme = "Dark" }.Save()); + + Assert.True(File.Exists(Path.Combine(moved, "settings.json"))); + Assert.Equal("Dark", AppSettings.Load().Theme); + } + finally + { + Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); + GitTestHelpers.TryDeleteDirectory(moved); + } + } } diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs index bd3f2d7..5ac4ff6 100644 --- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -43,10 +43,23 @@ public sealed class SyncAllCoordinatorTests : IDisposable - /// Synchronous progress: handler runs inline on the reporting thread. + /// + /// Synchronous progress: the handler runs inline on the reporting thread, one + /// report at a time. The view model's handler belongs to the UI thread and is + /// never entered twice at once there; several workers reporting at the same + /// moment must not enter it together here either. + /// private sealed class SyncProgress(Action handler) : IProgress { - public void Report(RepoProgress value) => handler(value); + private readonly Lock _oneAtATime = new(); + + public void Report(RepoProgress value) + { + lock(_oneAtATime) + { + handler(value); + } + } } @@ -213,11 +226,11 @@ public sealed class SyncAllCoordinatorTests : IDisposable Task run = _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None); - await entered.Task; // alpha's sync is in flight... + await entered.Task.WaitAsync(TimeSpan.FromSeconds(30)); // alpha's sync is in flight... Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // ...and beta has not started gate.SetResult(); - SyncAllResult result = await run; + SyncAllResult result = await run.WaitAsync(TimeSpan.FromSeconds(30)); Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false), result); Assert.Equal(new[] { "org-alpha", "org-beta" }, _lister.Organizations); @@ -336,10 +349,10 @@ public sealed class SyncAllCoordinatorTests : IDisposable using var cts = new CancellationTokenSource(); Task run = _coordinator.RunAsync(new[] { alpha, beta }, cts.Token); - await entered.Task; + await entered.Task.WaitAsync(TimeSpan.FromSeconds(30)); cts.Cancel(); // requested while alpha's sync is in flight... gate.SetResult(); // ...which must still run to completion - SyncAllResult result = await run; + SyncAllResult result = await run.WaitAsync(TimeSpan.FromSeconds(30)); Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true), result); Assert.Equal(SyncStatus.Done, alpha.Repos[0].Status); // finished, not canceled diff --git a/gclo.Engine.Tests/VaultEdgeTests.cs b/gclo.Engine.Tests/VaultEdgeTests.cs index 9d3fbab..ae70cc5 100644 --- a/gclo.Engine.Tests/VaultEdgeTests.cs +++ b/gclo.Engine.Tests/VaultEdgeTests.cs @@ -98,6 +98,28 @@ public sealed class VaultEdgeTests + /// A vault whose entries are named for this test run alone, never in the namespace the app uses. + private static CredentialManagerVault NewTestVault() + => new(isWindows: true, scope: "tests-" + Guid.NewGuid().ToString("N")[..12]); + + + + [Fact] + public void DefaultConstructor_NamesItsEntriesForTheDataFolder_AndFindsNothingForAnAccountNobodySaved() + { + if(!OperatingSystem.IsWindows()) + { + return; + } + + // The one test that uses the namespace of the real data folder, and it only reads. + var vault = new CredentialManagerVault(); + + Assert.Null(vault.TryRetrieve(Guid.NewGuid())); + } + + + [Fact] public void Store_TokenLargerThanCredManBlobLimit_SurfacesWin32Error() { @@ -107,7 +129,7 @@ public sealed class VaultEdgeTests } // CRED_MAX_CREDENTIAL_BLOB_SIZE is 2560 bytes; UTF-16 doubles the length. - var vault = new CredentialManagerVault(); + CredentialManagerVault vault = NewTestVault(); string oversized = new('x', 4000); Win32Exception ex = Assert.Throws(() => vault.Store(Guid.NewGuid(), oversized)); @@ -125,7 +147,7 @@ public sealed class VaultEdgeTests return; } - var vault = new CredentialManagerVault(); + CredentialManagerVault vault = NewTestVault(); string invalidTarget = new('x', 40_000); // far past CRED_MAX_GENERIC_TARGET_NAME_LENGTH Win32Exception ex = Assert.Throws(() => vault.TryRetrieve(invalidTarget, Guid.NewGuid())); @@ -143,7 +165,7 @@ public sealed class VaultEdgeTests return; } - var vault = new CredentialManagerVault(); + CredentialManagerVault vault = NewTestVault(); string invalidTarget = new('x', 40_000); Win32Exception ex = Assert.Throws(() => vault.Delete(invalidTarget, Guid.NewGuid())); diff --git a/gclo.Engine.Tests/VaultTests.cs b/gclo.Engine.Tests/VaultTests.cs index c5a73f4..36b7293 100644 --- a/gclo.Engine.Tests/VaultTests.cs +++ b/gclo.Engine.Tests/VaultTests.cs @@ -69,6 +69,17 @@ public sealed class VaultTests // ---------------------------------------------------------------- CredentialManagerVault + /// + /// A vault over the real Credential Manager whose entries are named for this test + /// run alone ("gclo:tests-...:account:..."), never in the namespace the installed + /// app and the CLI use: whatever a test writes or leaves behind is not an account's token. + /// + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static CredentialManagerVault NewTestVault() + => new(isWindows: true, scope: "tests-" + Guid.NewGuid().ToString("N")[..12]); + + + [Fact] public void CredentialManager_RoundTrips_AgainstTheRealStore() { @@ -79,7 +90,7 @@ public sealed class VaultTests return; } - var vault = new CredentialManagerVault(); + CredentialManagerVault vault = NewTestVault(); var id = Guid.NewGuid(); try { diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index c1b0710..7134d4f 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -30,6 +30,11 @@ public sealed class WorkspaceViewModelTests : IDisposable + /// Every temporary folder a test asked for; removed after it, whatever the test did with it. + private readonly List _folders = new(); + + + public void Dispose() { foreach(WorkspaceViewModel vm in _created) @@ -40,14 +45,45 @@ public sealed class WorkspaceViewModelTests : IDisposable TryDeleteDirectory(vm.TargetFolder); } } + foreach(string folder in _folders) + { + TryDeleteDirectory(folder); + } } - /// Synchronous progress: handler runs inline on the reporting thread. + /// + /// A folder name of this test's own under the temp folder. Remembered, so it is + /// removed afterwards also when the view model that synced into it was pointed + /// elsewhere later, or was never tracked. + /// + private string NewTempFolder() + { + string folder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + _folders.Add(folder); + return folder; + } + + + + /// + /// Synchronous progress: the handler runs inline on the reporting thread, one + /// report at a time. The view model's handler belongs to the UI thread and is + /// never entered twice at once there; several workers reporting at the same + /// moment must not enter it together here either. + /// private sealed class SyncProgress(Action handler) : IProgress { - public void Report(RepoProgress value) => handler(value); + private readonly Lock _oneAtATime = new(); + + public void Report(RepoProgress value) + { + lock(_oneAtATime) + { + handler(value); + } + } } @@ -100,7 +136,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.Organization = "acme"; vm.Token = "token-1234567890"; await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.TargetFolder = NewTempFolder(); vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads await vm.LoadReposCommand.ExecuteAsync(null); @@ -236,7 +272,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.Organization = "acme"; vm.Token = "token-1234567890"; await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.TargetFolder = NewTempFolder(); vm.MaxConcurrencyValue = 1; Assert.True(vm.CanEditInputs); @@ -1388,7 +1424,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.Organization = "acme"; vm.Token = "token-1234567890"; await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.TargetFolder = NewTempFolder(); await vm.LoadReposCommand.ExecuteAsync(null); int reportingThread = -1; _git.CloneHandler = (_, _, _, _, _) => @@ -1854,7 +1890,7 @@ public sealed class WorkspaceViewModelTests : IDisposable Id = Guid.NewGuid(), Name = "Work", Organization = "acme", - TargetRoot = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")), + TargetRoot = NewTempFolder(), }; var vault = new InMemoryVault(); vault.Store(account.Id, "vault-token-1234567890"); @@ -2000,7 +2036,7 @@ public sealed class WorkspaceViewModelTests : IDisposable [Fact] public async Task Sync_AccountWorkspace_RecordsTheResultOnTheStore() { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + string root = NewTempFolder(); try { var vault = new InMemoryVault(); @@ -2033,7 +2069,7 @@ public sealed class WorkspaceViewModelTests : IDisposable [Fact] public async Task Sync_QuickSync_DoesNotRecordOnTheStore() { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + string root = NewTempFolder(); try { var vault = new InMemoryVault(); @@ -2068,7 +2104,7 @@ public sealed class WorkspaceViewModelTests : IDisposable [Fact] public async Task Sync_RecordSyncResultFailure_StillCompletesTheRun() { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + string root = NewTempFolder(); string storeDir = Path.Combine(root, "store"); try { @@ -2221,7 +2257,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.Organization = "acme"; vm.Token = "token-1234567890"; await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.TargetFolder = NewTempFolder(); Task firstLoad = vm.LoadReposCommand.ExecuteAsync(null); first.SetResult([Repo("alpha")]); @@ -2382,7 +2418,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.Organization = "acme"; vm.Token = "token-1234567890"; await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.TargetFolder = NewTempFolder(); await vm.LoadReposCommand.ExecuteAsync(null); await vm.SyncCommand.ExecuteAsync(null); // captures the handler @@ -2401,8 +2437,8 @@ public sealed class WorkspaceViewModelTests : IDisposable - /// A folder of this test's own; the view model's target folder is removed when the test ends. - private static string NewTargetRoot() => Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + /// A folder of this test's own, removed when the test ends. + private string NewTargetRoot() => NewTempFolder(); diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index 9485164..f29cc2b 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -162,14 +162,16 @@ public sealed partial class SmokeTests : UiTestBase - // (7) Typing into the token box (which kicks the offline org-listing path) does - // not crash the app, and the org combo stays present. No token validation - // is asserted — these tests run without network access or credentials. + // (7) Typing into the token box does not crash the app, and the org combo + // stays present. The text is too short to be taken for a token, so nothing + // is looked up: these tests run without network access or credentials, and + // a longer made-up token would be sent to the real API to be refused. The + // listing itself is exercised offline, with the fixture token, in RepoTableTests. [Fact] public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent() { AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); - Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token"); + Session.EnterText(tokenBox, "ghp_short"); Assert.NotNull(Session.WaitForElement("ConnectOrgBox")); Assert.False(Session.App.HasExited); diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 320b08a..1d15691 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -54,7 +54,8 @@ public sealed class AppSettings - private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json"); + /// The settings file in , or in the data folder when none is named. + private static string SettingsPath(string? directory) => Path.Combine(directory ?? GcloPaths.DataRoot, "settings.json"); @@ -63,11 +64,13 @@ public sealed class AppSettings /// yields defaults, and loaded values are sanitized (concurrency clamped, theme validated). /// A file that is there but cannot be used is reported to . /// - public static AppSettings Load(IActivityLog? log = null) + /// Where a file that cannot be used is reported. + /// The folder that holds settings.json; the data folder () when null. + public static AppSettings Load(IActivityLog? log = null, string? directory = null) { try { - string path = SettingsPath; + string path = SettingsPath(directory); if(File.Exists(path)) { AppSettings? loaded = JsonSerializer.Deserialize( @@ -104,22 +107,24 @@ public sealed class AppSettings /// because losing a preference write must never crash the app; they are reported /// to and in the result. /// + /// Where a failure is reported. + /// The folder that holds settings.json; the data folder () when null. /// True when the settings are on disk. - public bool Save(IActivityLog? log = null) => Save(log, s_savePatience); + public bool Save(IActivityLog? log = null, string? directory = null) => Save(log, s_savePatience, directory); /// Saves with its own patience for a file that is in use, for tests. - internal bool Save(IActivityLog? log, TimeSpan patience) + internal bool Save(IActivityLog? log, TimeSpan patience, string? directory = null) { try { Sanitize(); - string path = SettingsPath; - string? directory = Path.GetDirectoryName(path); - if(!string.IsNullOrEmpty(directory)) + string path = SettingsPath(directory); + string? folder = Path.GetDirectoryName(path); + if(!string.IsNullOrEmpty(folder)) { - Directory.CreateDirectory(directory); + Directory.CreateDirectory(folder); } // A name of its own: two processes saving at once do not write one file.