diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e7fd56..4598571 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,6 +78,30 @@ jobs:
path: TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
+ ui-tests:
+ # End-to-end FlaUI/UIA tests against the real gclo.exe. Not in the branch
+ # ruleset's required checks yet: hosted-runner UIA is new here, so this job
+ # earns required status by proving itself stable first.
+ name: UI end-to-end tests (x64)
+ runs-on: windows-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Set up .NET SDK
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ # Self-contained Windows App SDK: hosted runners do not have the runtime
+ # installed. The output path is the same one AppSession resolves by default.
+ - name: Build app (unpackaged, self-contained Windows App SDK)
+ run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true
+
+ - name: Run UI tests
+ run: dotnet test gclo.UiTests/gclo.UiTests.csproj
+
format:
name: Format (style gate)
runs-on: windows-latest
diff --git a/gclo.UiTests/.editorconfig b/gclo.UiTests/.editorconfig
new file mode 100644
index 0000000..f3f6e18
--- /dev/null
+++ b/gclo.UiTests/.editorconfig
@@ -0,0 +1,6 @@
+# Analyzer tuning local to gclo.UiTests (inherits the repo root .editorconfig).
+# Mirrors the gclo.Engine.Tests suppressions, which are keyed to that project's
+# path in the root file and therefore do not reach this one.
+[*.cs]
+# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
+dotnet_diagnostic.CA1707.severity = none
diff --git a/gclo.UiTests/AppSession.cs b/gclo.UiTests/AppSession.cs
new file mode 100644
index 0000000..5ce9374
--- /dev/null
+++ b/gclo.UiTests/AppSession.cs
@@ -0,0 +1,418 @@
+using System.Diagnostics;
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Conditions;
+using FlaUI.Core.Definitions;
+using FlaUI.Core.Input;
+using FlaUI.Core.Tools;
+using FlaUI.UIA3;
+
+namespace gclo.UiTests;
+
+///
+/// One launched gclo.exe plus the UIA3 automation that drives it, shared by every
+/// UI test in the collection (see ).
+///
+/// The exe comes from the GCLO_UITEST_EXE environment variable when set, otherwise
+/// from the repo-relative Debug/x64 output (found by walking up from the test's
+/// base directory to the folder containing gclo.slnx). Each session gets a unique
+/// temp directory passed to the app as GCLO_DATA_DIR, so tests always start with
+/// no settings, no accounts, and no logs — and never touch the real user profile.
+///
+/// Dispose closes the app (kill as fallback), disposes the automation only after
+/// the app is gone, and best-effort deletes the temp data directory.
+///
+public sealed class AppSession : IDisposable
+{
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
+
+ /// Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window.
+ public AppSession()
+ {
+ ExePath = ResolveExePath();
+ DataDirectory = Path.Combine(Path.GetTempPath(), "gclo-uitests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(DataDirectory);
+
+ var startInfo = new ProcessStartInfo(ExePath)
+ {
+ WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "",
+ };
+ startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory;
+
+ Automation = new UIA3Automation();
+ try
+ {
+ App = Application.Launch(startInfo);
+ MainWindow = WaitForMainWindow();
+ }
+ catch
+ {
+ // A half-launched session must not leak the process, the automation,
+ // or the temp directory; xunit will surface the original exception.
+ Dispose();
+ throw;
+ }
+ }
+
+ /// The gclo.exe under test.
+ public string ExePath { get; }
+
+ /// The temp directory the app sees as its data root (via GCLO_DATA_DIR).
+ public string DataDirectory { get; }
+
+ /// The UIA3 automation used for every lookup and pattern call.
+ public UIA3Automation Automation { get; }
+
+ /// The launched application (never null after construction).
+ public Application App { get; }
+
+ /// The app's main window, resolved once at launch.
+ public Window MainWindow { get; }
+
+ // ---------------------------------------------------------------- lookups
+
+ ///
+ /// Finds the first element matching in the main
+ /// window — or in any other top-level window of the app's process. The second
+ /// leg matters because WinAppSDK menu flyouts open in windowed popups (separate
+ /// top-level HWNDs), so their items are not descendants of the main window.
+ ///
+ public AutomationElement? FindInApp(Func condition)
+ {
+ AutomationElement? found = MainWindow.FindFirstDescendant(condition);
+ if (found is not null)
+ {
+ return found;
+ }
+
+ foreach (AutomationElement topLevel in Automation.GetDesktop()
+ .FindAllChildren(cf => cf.ByProcessId(App.ProcessId)))
+ {
+ if (topLevel.Equals(MainWindow))
+ {
+ continue;
+ }
+ found = topLevel.FindFirstDescendant(condition);
+ if (found is not null)
+ {
+ return found;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Retries until it returns an element, throwing a
+ /// naming when the
+ /// timeout (default 10s) elapses first. Exceptions inside the lookup (stale
+ /// elements mid-transition) count as "not found yet" and are retried.
+ ///
+ public AutomationElement WaitFor(
+ Func lookup, string description, TimeSpan? timeout = null)
+ {
+ ArgumentNullException.ThrowIfNull(lookup);
+ TimeSpan effective = timeout ?? DefaultTimeout;
+ RetryResult result = Retry.WhileNull(
+ lookup, timeout: effective, interval: PollInterval, ignoreException: true);
+ if (result.Result is { } element)
+ {
+ return element;
+ }
+ throw new TimeoutException(
+ $"Timed out after {effective.TotalSeconds:0}s waiting for {description}. "
+ + $"(app exited: {App.HasExited})");
+ }
+
+ ///
+ /// Retries until returns null (the element left the
+ /// tree), throwing a naming
+ /// when it is still present at the timeout.
+ ///
+ public static void WaitUntilGone(
+ Func lookup, string description, TimeSpan? timeout = null)
+ {
+ ArgumentNullException.ThrowIfNull(lookup);
+ TimeSpan effective = timeout ?? DefaultTimeout;
+ RetryResult result = Retry.WhileTrue(
+ () => lookup() is not null, timeout: effective, interval: PollInterval, ignoreException: true);
+ if (!result.Success)
+ {
+ throw new TimeoutException(
+ $"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away.");
+ }
+ }
+
+ /// Waits for the element carrying .
+ public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
+ => WaitFor(
+ () => FindInApp(cf => cf.ByAutomationId(automationId)),
+ $"element '{automationId}'",
+ timeout);
+
+ /// Waits until no element carries anymore.
+ public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
+ => WaitUntilGone(
+ () => FindInApp(cf => cf.ByAutomationId(automationId)),
+ $"element '{automationId}'",
+ timeout);
+
+ /// Waits for a text element whose name is exactly .
+ public AutomationElement WaitForText(string text, TimeSpan? timeout = null)
+ => WaitFor(
+ () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
+ $"text '{text}'",
+ timeout);
+
+ /// Waits until no text element named remains.
+ public void WaitForTextGone(string text, TimeSpan? timeout = null)
+ => WaitUntilGone(
+ () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
+ $"text '{text}'",
+ timeout);
+
+ // ---------------------------------------------------------------- actions
+
+ ///
+ /// Opens the menu bar item named (e.g. "File")
+ /// and invokes the flyout item carrying .
+ /// The bar item is opened via its ExpandCollapse pattern (WinUI's MenuBarItem
+ /// exposes it) with Invoke as fallback; the flyout item is then looked up across
+ /// the app's windows because menu popups are windowed in WinAppSDK.
+ ///
+ public void InvokeMenuItem(string menuBarItemName, string itemAutomationId)
+ {
+ AutomationElement barItem = WaitFor(
+ () => MainWindow.FindFirstDescendant(cf =>
+ cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuBarItemName)))
+ ?? MainWindow.FindFirstDescendant(cf => cf.ByName(menuBarItemName)),
+ $"menu bar item '{menuBarItemName}'");
+
+ if (barItem.Patterns.ExpandCollapse.TryGetPattern(out var expandCollapse))
+ {
+ expandCollapse.Expand();
+ Wait.UntilInputIsProcessed();
+ }
+ else
+ {
+ InvokeElement(barItem);
+ }
+
+ AutomationElement item = WaitFor(
+ () => FindInApp(cf => cf.ByAutomationId(itemAutomationId)),
+ $"menu item '{itemAutomationId}' under '{menuBarItemName}'");
+ InvokeElement(item);
+ }
+
+ ///
+ /// Activates a NavigationView item by AutomationId. NavigationViewItem exposes
+ /// Invoke (preferred, it routes through the pane's selection logic) and
+ /// SelectionItem; a raw click is the last resort.
+ ///
+ public void InvokeNavItem(string automationId)
+ => InvokeElement(WaitForElement(automationId));
+
+ ///
+ /// Invokes the XAML button named . The ClassName filter
+ /// ("Button") keeps ContentDialog buttons (real XAML buttons named by their
+ /// *ButtonText) from colliding with the window's non-client caption buttons,
+ /// whose UIA names ("Close", ...) overlap dialog button captions.
+ ///
+ public void ClickButton(string name, TimeSpan? timeout = null)
+ {
+ AutomationElement button = WaitFor(
+ () => FindInApp(cf => cf.ByControlType(ControlType.Button)
+ .And(cf.ByName(name))
+ .And(cf.ByClassName("Button"))),
+ $"button '{name}'",
+ timeout);
+ InvokeElement(button);
+ }
+
+ ///
+ /// Best-effort variant of for finally cleanup:
+ /// clicks the button when it is currently present and swallows every failure,
+ /// so closing a leftover dialog can never mask the test's own exception.
+ ///
+ public void TryClickButton(string name)
+ {
+ try
+ {
+ AutomationElement? button = FindInApp(cf => cf.ByControlType(ControlType.Button)
+ .And(cf.ByName(name))
+ .And(cf.ByClassName("Button")));
+ if (button is not null)
+ {
+ InvokeElement(button);
+ }
+ }
+ catch
+ {
+ // Cleanup only — the test outcome was decided in the try body.
+ }
+ }
+
+ ///
+ /// Writes into : the UIA Value
+ /// pattern when available and writable (works without window focus), otherwise
+ /// focus plus real keystrokes via .
+ ///
+ public void EnterText(AutomationElement element, string text)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+ try
+ {
+ if (element.Patterns.Value.TryGetPattern(out var value)
+ && !value.IsReadOnly.ValueOrDefault)
+ {
+ value.SetValue(text);
+ return;
+ }
+ }
+ catch
+ {
+ // Some boxes reject SetValue (or the element re-rendered); type instead.
+ }
+
+ MainWindow.Focus();
+ element.Focus();
+ Keyboard.Type(text);
+ Wait.UntilInputIsProcessed();
+ }
+
+ /// Invoke pattern first, SelectionItem second, mouse click as last resort.
+ private static void InvokeElement(AutomationElement element)
+ {
+ if (element.Patterns.Invoke.TryGetPattern(out var invoke))
+ {
+ invoke.Invoke();
+ }
+ else if (element.Patterns.SelectionItem.TryGetPattern(out var selectionItem))
+ {
+ selectionItem.Select();
+ }
+ else
+ {
+ element.Click();
+ }
+ Wait.UntilInputIsProcessed();
+ }
+
+ // ---------------------------------------------------------------- lifecycle
+
+ private Window WaitForMainWindow()
+ {
+ RetryResult result = Retry.WhileNull(
+ () =>
+ {
+ if (App.HasExited)
+ {
+ throw new InvalidOperationException(
+ $"gclo exited while waiting for its main window (exit code {App.ExitCode}). "
+ + $"Exe: {ExePath}");
+ }
+ try
+ {
+ return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500));
+ }
+ catch
+ {
+ return null; // not ready yet; retried until the outer timeout
+ }
+ },
+ timeout: LaunchTimeout,
+ interval: PollInterval);
+
+ if (result.Result is { } window)
+ {
+ return window;
+ }
+ throw new TimeoutException(
+ $"gclo's main window did not appear within {LaunchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
+ }
+
+ ///
+ /// The exe to drive: GCLO_UITEST_EXE when set, else the repo's Debug/x64 output,
+ /// located by walking up from the test assembly to the folder holding gclo.slnx.
+ ///
+ private static string ResolveExePath()
+ {
+ string? fromEnv = Environment.GetEnvironmentVariable("GCLO_UITEST_EXE");
+ if (!string.IsNullOrWhiteSpace(fromEnv))
+ {
+ if (!File.Exists(fromEnv))
+ {
+ throw new FileNotFoundException(
+ $"GCLO_UITEST_EXE points at '{fromEnv}', which does not exist.", fromEnv);
+ }
+ return fromEnv;
+ }
+
+ for (DirectoryInfo? current = new(AppContext.BaseDirectory);
+ current is not null;
+ current = current.Parent)
+ {
+ if (!File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
+ {
+ continue;
+ }
+
+ string exePath = Path.Combine(
+ current.FullName, "gclo", "bin", "x64", "Debug",
+ "net10.0-windows10.0.19041.0", "win-x64", "gclo.exe");
+ if (!File.Exists(exePath))
+ {
+ throw new FileNotFoundException(
+ $"gclo.exe not found at '{exePath}'. Build the app first: "
+ + "dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None "
+ + "— or set GCLO_UITEST_EXE to the exe to test.",
+ exePath);
+ }
+ return exePath;
+ }
+
+ throw new FileNotFoundException(
+ $"Could not find the repo root (no gclo.slnx above '{AppContext.BaseDirectory}'). "
+ + "Set GCLO_UITEST_EXE to the gclo.exe to test.");
+ }
+
+ ///
+ public void Dispose()
+ {
+ // Order matters: take the app down first, dispose the automation only after
+ // the process is gone, then sweep the temp data directory. Every step is
+ // best-effort so one failure cannot mask the test outcome.
+ try
+ {
+ App?.Close(); // FlaUI kills the process itself if closing stalls
+ }
+ catch
+ {
+ // The process may already be gone; Kill below double-checks.
+ }
+ try
+ {
+ if (App is { HasExited: false })
+ {
+ App.Kill();
+ }
+ }
+ catch
+ {
+ // Best effort only.
+ }
+ App?.Dispose();
+ Automation?.Dispose();
+
+ try
+ {
+ Directory.Delete(DataDirectory, recursive: true);
+ }
+ catch
+ {
+ // A straggling handle keeps the directory; it lives under %TEMP% anyway.
+ }
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs
new file mode 100644
index 0000000..39279b5
--- /dev/null
+++ b/gclo.UiTests/SmokeTests.cs
@@ -0,0 +1,177 @@
+using System.Text.RegularExpressions;
+using FlaUI.Core.AutomationElements;
+
+namespace gclo.UiTests;
+
+///
+/// Token-free, deterministic smoke tests against the launched app: the shell and
+/// its navigation render, the core dialogs open and close, and offline input does
+/// not crash anything. No test talks to GitHub — the app runs on an isolated
+/// GCLO_DATA_DIR with no accounts and no stored settings (see ).
+/// Dialog tests close what they open on the happy path and fall back to a
+/// best-effort in finally, so one
+/// failure cannot strand a dialog in front of the next test.
+///
+[Collection(nameof(UiSessionTests))]
+public sealed partial class SmokeTests : UiTestBase
+{
+ /// Small pause after a button invoke whose handler runs behind a dialog deferral.
+ private static readonly TimeSpan PostInvokeSettle = TimeSpan.FromMilliseconds(300);
+
+ public SmokeTests(AppSession session)
+ : base(session)
+ {
+ }
+
+ // (1) The app launches and puts up its shell window.
+ [Fact]
+ public void MainWindow_AfterLaunch_TitleContainsGclo()
+ => Assert.Contains("gclo", Session.MainWindow.Title, StringComparison.Ordinal);
+
+ // (2) The navigation pane carries the pinned Quick Sync plus both footer commands.
+ [Fact]
+ public void NavigationPane_AfterLaunch_ShowsQuickSyncAddAccountAndSyncAll()
+ {
+ Assert.NotNull(Session.WaitForElement("NavQuickSync"));
+ Assert.NotNull(Session.WaitForElement("NavAddAccount"));
+ Assert.NotNull(Session.WaitForElement("NavSyncAll"));
+ }
+
+ // (3) With no accounts and no prior load, the Quick Sync workspace shows the
+ // connect card, and the 'Sync all' footer command has nothing to run.
+ [Fact]
+ public void ConnectCard_FreshDataDir_ShowsTokenAndLoadAndDisablesSyncAll()
+ {
+ Assert.NotNull(Session.WaitForElement("ConnectTokenBox"));
+ Assert.NotNull(Session.WaitForElement("LoadReposButton"));
+
+ AutomationElement syncAll = Session.WaitForElement("NavSyncAll");
+ Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist.");
+ }
+
+ // (4) File -> Settings opens the Settings dialog; Cancel closes it.
+ [Fact]
+ public void FileMenu_Settings_OpensDialogAndCancelCloses()
+ {
+ Session.InvokeMenuItem("File", "MenuSettings");
+ try
+ {
+ Session.WaitForText("Settings"); // the ContentDialog's title
+ Session.ClickButton("Cancel");
+ Session.WaitForTextGone("Settings");
+ }
+ finally
+ {
+ Session.TryClickButton("Cancel"); // no-op when the happy path closed it
+ }
+ }
+
+ // (5) Help -> About shows the semantic version plus the parenthesized git hash.
+ [Fact]
+ public void HelpMenu_About_ShowsSemanticVersionWithCommitHash()
+ {
+ Session.InvokeMenuItem("Help", "MenuAbout");
+ try
+ {
+ // The TextBlock exists as soon as the dialog opens; retry until its
+ // text has been populated by the AboutDialog constructor.
+ AutomationElement version = Session.WaitFor(
+ () =>
+ {
+ AutomationElement? element =
+ Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText"));
+ return element is not null
+ && element.Name.StartsWith("Version ", StringComparison.Ordinal)
+ ? element
+ : null;
+ },
+ "the About dialog's version text");
+
+ string text = version.Name;
+ Assert.Matches(VersionPattern(), text);
+ Assert.Contains("(", text, StringComparison.Ordinal); // "()"
+
+ Session.ClickButton("Close");
+ Session.WaitForElementGone("AboutVersionText");
+ }
+ finally
+ {
+ Session.TryClickButton("Close"); // no-op when the happy path closed it
+ }
+ }
+
+ // (6) The Add account wizard appears; Next with an empty name keeps it open
+ // (step 1 refuses to advance); Cancel closes it.
+ [Fact]
+ public void AddAccountWizard_NextWithEmptyName_StaysOpenAndCancelCloses()
+ {
+ Session.InvokeNavItem("NavAddAccount");
+ try
+ {
+ Session.WaitForElement("WizardNameBox"); // wizard step 1 is on screen
+ Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step
+
+ Session.ClickButton("Next");
+ Thread.Sleep(PostInvokeSettle); // validation runs behind the dialog deferral
+
+ Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open
+ Assert.False(Session.App.HasExited);
+
+ Session.ClickButton("Cancel");
+ Session.WaitForElementGone("WizardNameBox");
+ }
+ finally
+ {
+ Session.TryClickButton("Cancel"); // no-op when the happy path closed it
+ }
+ }
+
+ // (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.
+ [Fact]
+ public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent()
+ {
+ AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox");
+ Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token");
+
+ Assert.NotNull(Session.WaitForElement("ConnectOrgBox"));
+ Assert.False(Session.App.HasExited);
+ }
+
+ // (8) Regression for the dialog-collision crash: WinUI allows one open
+ // ContentDialog per XamlRoot, and a second ShowAsync throws a COMException
+ // that — escaping an async void handler — used to kill the whole process
+ // (0xc000027b). Commanding 'Add account' while About is open must now be
+ // ignored: the app stays alive, About stays up, and no wizard appears.
+ [Fact]
+ public void AddAccountWhileAboutDialogOpen_IsIgnoredAndAppSurvives()
+ {
+ Session.InvokeMenuItem("Help", "MenuAbout");
+ try
+ {
+ Session.WaitForElement("AboutVersionText");
+
+ Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op
+ Thread.Sleep(PostInvokeSettle);
+
+ Assert.False(Session.App.HasExited);
+ Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox")));
+ Assert.NotNull(Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText")));
+
+ Session.ClickButton("Close");
+ Session.WaitForElementGone("AboutVersionText");
+ }
+ finally
+ {
+ Session.TryClickButton("Close"); // no-op when the happy path closed it
+ }
+ }
+
+ // (9) Resizing below the minimum window size is intentionally not covered:
+ // driving OS-level resize through UIA transforms is flaky across DPI
+ // scales, and the minimum is already enforced by OverlappedPresenter.
+
+ [GeneratedRegex(@"^Version \d+\.\d+\.\d+")]
+ private static partial Regex VersionPattern();
+}
diff --git a/gclo.UiTests/UiSessionTests.cs b/gclo.UiTests/UiSessionTests.cs
new file mode 100644
index 0000000..5e4681b
--- /dev/null
+++ b/gclo.UiTests/UiSessionTests.cs
@@ -0,0 +1,31 @@
+namespace gclo.UiTests;
+
+///
+/// The single xunit collection every UI test belongs to (referenced via
+/// nameof(UiSessionTests)). All UI tests share one launched gclo instance
+/// (the collection fixture) and run sequentially
+/// (DisableParallelization), so two tests never drive two overlapping app
+/// instances or interleave input on the same one.
+///
+[CollectionDefinition(nameof(UiSessionTests), DisableParallelization = true)]
+public sealed class UiSessionTests : ICollectionFixture
+{
+}
+
+///
+/// Base class for UI tests: holds the shared so test
+/// bodies read as session-relative steps. Derived classes must carry
+/// [Collection(nameof(UiSessionTests))] — xunit does not inherit it.
+///
+public abstract class UiTestBase
+{
+ /// Captures the collection fixture xunit injects per test class.
+ protected UiTestBase(AppSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ Session = session;
+ }
+
+ /// The app instance shared by every test in the collection.
+ protected AppSession Session { get; }
+}
diff --git a/gclo.UiTests/gclo.UiTests.csproj b/gclo.UiTests/gclo.UiTests.csproj
new file mode 100644
index 0000000..0b3ddf2
--- /dev/null
+++ b/gclo.UiTests/gclo.UiTests.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+ net10.0-windows10.0.19041.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs
index b50f179..efdf04d 100644
--- a/gclo.ViewModels/AccountsStore.cs
+++ b/gclo.ViewModels/AccountsStore.cs
@@ -5,7 +5,8 @@ using gclo.Engine;
namespace gclo.ViewModels;
///
-/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their
+/// Persists account profiles as JSON at accounts.json under
+/// (default %LOCALAPPDATA%\gclo) and their
/// tokens in an ; the file holds metadata only and never a
/// token. Unlike , accounts are primary user data, so write
/// failures in , , and
@@ -22,17 +23,16 @@ public sealed class AccountsStore
///
/// Loads existing accounts from (default:
- /// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt
- /// or unreadable one is logged (pass the app's to surface
- /// that) and treated as empty rather than blocking startup.
+ /// ). A missing file simply means no accounts yet;
+ /// a corrupt or unreadable one is logged (pass the app's to
+ /// surface that) and treated as empty rather than blocking startup.
///
public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
_log = log ?? new NullActivityLog();
- directory ??= Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
+ directory ??= GcloPaths.DataRoot;
_filePath = Path.Combine(directory, "accounts.json");
_accounts = Load();
}
diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs
index 574bfd0..3692af1 100644
--- a/gclo.ViewModels/AppSettings.cs
+++ b/gclo.ViewModels/AppSettings.cs
@@ -4,9 +4,10 @@ using System.Text.Json.Serialization;
namespace gclo.ViewModels;
///
-/// User preferences persisted as JSON at %LOCALAPPDATA%\gclo\settings.json.
-/// Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData) so the same
-/// path works whether the app runs packaged or unpackaged.
+/// User preferences persisted as JSON at settings.json under
+/// (default %LOCALAPPDATA%\gclo, overridable via
+/// GCLO_DATA_DIR). Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData)
+/// so the same path works whether the app runs packaged or unpackaged.
///
public sealed class AppSettings
{
@@ -23,10 +24,7 @@ public sealed class AppSettings
/// App theme: "System", "Light", or "Dark". Unknown values fall back to "System".
public string Theme { get; set; } = "System";
- private static string SettingsPath => Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "gclo",
- "settings.json");
+ private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json");
///
/// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file
diff --git a/gclo.ViewModels/GcloPaths.cs b/gclo.ViewModels/GcloPaths.cs
new file mode 100644
index 0000000..4ca28bf
--- /dev/null
+++ b/gclo.ViewModels/GcloPaths.cs
@@ -0,0 +1,22 @@
+namespace gclo.ViewModels;
+
+///
+/// The single seam for where gclo keeps its per-user data (settings, accounts,
+/// logs). Defaults to %LOCALAPPDATA%\gclo; the GCLO_DATA_DIR environment variable
+/// overrides it so UI tests (and portable setups) can point the whole app at an
+/// isolated directory without touching the real profile. Components that accept
+/// an explicit directory (test seams) are unaffected — this only feeds defaults.
+///
+public static class GcloPaths
+{
+ ///
+ /// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and
+ /// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a
+ /// value set before launch always wins over any cached default.
+ ///
+ public static string DataRoot =>
+ Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir
+ ? dir
+ : Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
+}
diff --git a/gclo.slnx b/gclo.slnx
index af49b94..9c4e8a8 100644
--- a/gclo.slnx
+++ b/gclo.slnx
@@ -7,6 +7,7 @@
+
diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml
index ab703ed..869b78c 100644
--- a/gclo/AboutDialog.xaml
+++ b/gclo/AboutDialog.xaml
@@ -21,6 +21,7 @@
TextWrapping="Wrap" />
diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml
index 8b90ddd..f605ac8 100644
--- a/gclo/AccountWizardDialog.xaml
+++ b/gclo/AccountWizardDialog.xaml
@@ -28,6 +28,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 1 of 4: Identity" />
@@ -53,6 +54,7 @@
Text="Step 2 of 4: Access token" />
@@ -94,6 +96,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 3 of 4: Organization" />
diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs
index 0c4c833..3782190 100644
--- a/gclo/App.xaml.cs
+++ b/gclo/App.xaml.cs
@@ -1,3 +1,4 @@
+using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
@@ -10,6 +11,12 @@ namespace gclo
{
private Window? _window;
+ ///
+ /// Where the unhandled-exception net writes; set by MainWindow once the shared
+ /// activity log exists. Null only during the first instants of startup.
+ ///
+ internal static IActivityLog? CrashLog { get; set; }
+
///
/// Constructed by Program.Main inside Application.Start after VelopackApp handled
/// install/update hooks — see Program.cs.
@@ -18,6 +25,26 @@ namespace gclo
{
InitializeComponent();
+ // Last-resort net: an exception escaping an async void event handler is
+ // rethrown on the dispatcher with no frame above it, and the process dies
+ // as a stowed exception (0xc000027b) with nothing in the log. The known
+ // collision (two ContentDialogs) is prevented at the source by
+ // DialogGuard; this catches whatever else slips through, records it, and
+ // keeps the app running.
+ UnhandledException += (_, e) =>
+ {
+ e.Handled = true;
+ try
+ {
+ CrashLog?.Error($"Unhandled UI exception: {e.Message}", e.Exception);
+ }
+ catch
+ {
+ // Logging must never turn a survivable error fatal; the stowed
+ // Exception property itself can throw when type info was lost.
+ }
+ };
+
// App-level so Application.Current.Resources theme lookups (the status
// brushes) agree with the visible theme. Only settable here, before any
// window exists; mid-session theme changes still use the root element's
@@ -34,7 +61,9 @@ namespace gclo
}
///
- /// Invoked when the application is launched.
+ /// Invoked when the application is launched. The branded splash is an overlay
+ /// inside (see for why a
+ /// separate splash window is a UIA crash hazard).
///
/// Details about the launch request and process.
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
diff --git a/gclo/DialogGuard.cs b/gclo/DialogGuard.cs
new file mode 100644
index 0000000..2705648
--- /dev/null
+++ b/gclo/DialogGuard.cs
@@ -0,0 +1,43 @@
+using System; // the GetAwaiter extension for IAsyncOperation lives here (CsWinRT)
+using System.Threading.Tasks;
+using Microsoft.UI.Xaml.Controls;
+
+namespace gclo
+{
+ ///
+ /// Serializes ContentDialog display. WinUI allows a single open ContentDialog
+ /// per XamlRoot, and a second ShowAsync does not queue — it throws a
+ /// COMException which, escaping an async void event handler, kills the process
+ /// with a stowed exception (0xc000027b). Every dialog in the app opens through
+ /// , which ignores the request while another dialog is
+ /// on screen — the same no-op a user expects from commands behind a modal.
+ /// All calls run on the single UI thread, so the flag needs no locking.
+ ///
+ internal static class DialogGuard
+ {
+ private static bool _dialogOpen;
+
+ ///
+ /// Shows unless another dialog is on screen.
+ /// Returns the dialog result, or null when the request was ignored —
+ /// callers treat null like a dismissal.
+ ///
+ public static async Task ShowAsync(ContentDialog dialog)
+ {
+ if (_dialogOpen)
+ {
+ return null;
+ }
+
+ _dialogOpen = true;
+ try
+ {
+ return await dialog.ShowAsync();
+ }
+ finally
+ {
+ _dialogOpen = false;
+ }
+ }
+ }
+}
diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml
index cc2091b..b686399 100644
--- a/gclo/MainWindow.xaml
+++ b/gclo/MainWindow.xaml
@@ -21,18 +21,18 @@
-
+
-
+
-
+
-
+
-
+
@@ -45,6 +45,7 @@
Content hosts the selected workspace's cached WorkspacePage. -->
-
+
@@ -62,6 +66,7 @@
@@ -69,12 +74,22 @@
-
+
+
+
+
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index a12a537..91ab8d4 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -66,7 +66,8 @@ namespace gclo
public MainWindow()
{
- _log = new FileActivityLog();
+ _log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs"));
+ App.CrashLog = _log; // the unhandled-exception net now reaches the activity log
_tokenVault = new CredentialManagerVault();
_accountsStore = new AccountsStore(_tokenVault, log: _log);
@@ -109,8 +110,19 @@ namespace gclo
}
Closed += (_, _) => DisposeWorkspaces();
+
+ // Dismiss the startup splash overlay once it has been visible long enough
+ // to read as an intentional brand moment rather than a flicker.
+ DispatcherQueue.TryEnqueue(async () =>
+ {
+ await Task.Delay(MinimumSplashMilliseconds);
+ await StartupSplash.DismissAsync();
+ });
}
+ /// Minimum splash display, so a fast startup never shows a flicker.
+ private const int MinimumSplashMilliseconds = 800;
+
private void ApplySettings()
{
if (Content is FrameworkElement root)
@@ -389,7 +401,7 @@ namespace gclo
{
XamlRoot = Content.XamlRoot,
};
- await dialog.ShowAsync();
+ await DialogGuard.ShowAsync(dialog);
if (!dialog.Saved)
{
return;
@@ -486,7 +498,7 @@ namespace gclo
DefaultButton = ContentDialogButton.Close,
XamlRoot = Content.XamlRoot,
};
- if (await confirm.ShowAsync() != ContentDialogResult.Primary)
+ if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
return;
}
@@ -598,9 +610,9 @@ namespace gclo
catch (Exception ex)
{
// Unexpected only: the coordinator reports per-account trouble by
- // skipping. (Also lands here when the summary dialog cannot open
- // because another ContentDialog is showing; the activity log carries
- // the per-account outcomes either way.)
+ // skipping, and DialogGuard turns a blocked summary dialog into a
+ // no-op instead of a throw; the activity log carries the
+ // per-account outcomes either way.
_log.Error($"Sync all did not finish cleanly: {ex.Message}", ex);
}
finally
@@ -651,7 +663,7 @@ namespace gclo
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot };
- if (await dialog.ShowAsync() == ContentDialogResult.Primary)
+ if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
dialog.ApplyAndSave();
ApplySettings();
@@ -663,7 +675,7 @@ namespace gclo
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
- await dialog.ShowAsync();
+ await DialogGuard.ShowAsync(dialog);
}
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
@@ -674,7 +686,7 @@ namespace gclo
private async void AboutMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new AboutDialog { XamlRoot = Content.XamlRoot };
- await dialog.ShowAsync();
+ await DialogGuard.ShowAsync(dialog);
}
private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e)
@@ -713,7 +725,7 @@ namespace gclo
DefaultButton = ContentDialogButton.Primary,
XamlRoot = Content.XamlRoot,
};
- if (await confirm.ShowAsync() != ContentDialogResult.Primary)
+ if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
return;
}
@@ -736,7 +748,7 @@ namespace gclo
CloseButtonText = "OK",
XamlRoot = Content.XamlRoot,
};
- await dialog.ShowAsync();
+ await DialogGuard.ShowAsync(dialog);
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
diff --git a/gclo/SplashOverlay.xaml b/gclo/SplashOverlay.xaml
new file mode 100644
index 0000000..cc29045
--- /dev/null
+++ b/gclo/SplashOverlay.xaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
+
diff --git a/gclo/SplashOverlay.xaml.cs b/gclo/SplashOverlay.xaml.cs
new file mode 100644
index 0000000..4d4df88
--- /dev/null
+++ b/gclo/SplashOverlay.xaml.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+using Microsoft.UI.Xaml.Controls;
+
+namespace gclo
+{
+ ///
+ /// Branded startup splash rendered as a full-window overlay inside
+ /// . An overlay instead of a separate splash window by
+ /// design: closing a real window while assistive technology or UI automation is
+ /// querying it crashes the process with a stowed XAML exception, whereas fading
+ /// out and removing an element is ordinary, race-free XAML work.
+ ///
+ public sealed partial class SplashOverlay : UserControl
+ {
+ private bool _dismissed;
+
+ /// Builds the overlay and stamps the build identity line.
+ public SplashOverlay()
+ {
+ InitializeComponent();
+ VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly());
+ }
+
+ ///
+ /// Fades the overlay out and removes it from its parent panel. Safe to call
+ /// more than once; only the first call does anything.
+ ///
+ public async Task DismissAsync()
+ {
+ if (_dismissed)
+ {
+ return;
+ }
+ _dismissed = true;
+
+ // Input must fall through to the app the moment dismissal starts.
+ IsHitTestVisible = false;
+
+ var completion = new TaskCompletionSource();
+ FadeOut.Completed += (_, _) => completion.TrySetResult();
+ FadeOut.Begin();
+ await completion.Task;
+
+ (Parent as Panel)?.Children.Remove(this);
+ }
+ }
+}
diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml
index 6706e10..1c03cb8 100644
--- a/gclo/WorkspacePage.xaml
+++ b/gclo/WorkspacePage.xaml
@@ -41,6 +41,7 @@
@@ -55,6 +56,7 @@
@@ -103,6 +107,7 @@