test: the suites write nothing outside their own folders and send nothing anywhere #162

Merged
james.maes merged 2 commits from test/161-test-safety into main 2026-11-10 23:02:43 +00:00
10 changed files with 188 additions and 69 deletions

No files matched your search

+15 -1
View File
@@ -37,9 +37,23 @@ public sealed class ActionLoggingTests : IDisposable
/// <summary>
/// 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.
/// </summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
private readonly Lock _oneAtATime = new();
public void Report(RepoProgress value)
{
lock(_oneAtATime)
{
handler(value);
}
}
}
+16 -25
View File
@@ -10,7 +10,6 @@ namespace gclo.Engine.Tests;
/// <summary>Pins defaults and load-time sanitizing of <see cref="AppSettings"/>.</summary>
[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<bool> 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);
});
}
+7 -7
View File
@@ -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.
/// </summary>
[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);
}
}
+25
View File
@@ -77,4 +77,29 @@ public sealed class GcloPathsTests
Assert.Equal(Path.Combine(GcloPaths.DefaultDataRoot, "logs"), GcloPaths.LogDirectory);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
}
+19 -6
View File
@@ -43,10 +43,23 @@ public sealed class SyncAllCoordinatorTests : IDisposable
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
/// <summary>
/// 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.
/// </summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
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<SyncAllResult> 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<SyncAllResult> 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
+25 -3
View File
@@ -98,6 +98,28 @@ public sealed class VaultEdgeTests
/// <summary>A vault whose entries are named for this test run alone, never in the namespace the app uses.</summary>
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<Win32Exception>(() => 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<Win32Exception>(() => 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<Win32Exception>(() => vault.Delete(invalidTarget, Guid.NewGuid()));
+12 -1
View File
@@ -69,6 +69,17 @@ public sealed class VaultTests
// ---------------------------------------------------------------- CredentialManagerVault
/// <summary>
/// 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.
/// </summary>
[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
{
+49 -13
View File
@@ -30,6 +30,11 @@ public sealed class WorkspaceViewModelTests : IDisposable
/// <summary>Every temporary folder a test asked for; removed after it, whatever the test did with it.</summary>
private readonly List<string> _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);
}
}
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
/// <summary>
/// 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.
/// </summary>
private string NewTempFolder()
{
string folder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
_folders.Add(folder);
return folder;
}
/// <summary>
/// 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.
/// </summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
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
/// <summary>A folder of this test's own; the view model's target folder is removed when the test ends.</summary>
private static string NewTargetRoot() => Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
/// <summary>A folder of this test's own, removed when the test ends.</summary>
private string NewTargetRoot() => NewTempFolder();
+6 -4
View File
@@ -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);
+14 -9
View File
@@ -54,7 +54,8 @@ public sealed class AppSettings
private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json");
/// <summary>The settings file in <paramref name="directory"/>, or in the data folder when none is named.</summary>
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 <paramref name="log"/>.
/// </summary>
public static AppSettings Load(IActivityLog? log = null)
/// <param name="log">Where a file that cannot be used is reported.</param>
/// <param name="directory">The folder that holds settings.json; the data folder (<see cref="GcloPaths.DataRoot"/>) when null.</param>
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 <paramref name="log"/> and in the result.
/// </summary>
/// <param name="log">Where a failure is reported.</param>
/// <param name="directory">The folder that holds settings.json; the data folder (<see cref="GcloPaths.DataRoot"/>) when null.</param>
/// <returns>True when the settings are on disk.</returns>
public bool Save(IActivityLog? log = null) => Save(log, s_savePatience);
public bool Save(IActivityLog? log = null, string? directory = null) => Save(log, s_savePatience, directory);
/// <summary>Saves with its own patience for a file that is in use, for tests.</summary>
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.