Dialog-collision crash fix, FlaUI e2e suite, branded splash (beta.10) #29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// One launched gclo.exe plus the UIA3 automation that drives it, shared by every
|
||||
/// UI test in the collection (see <see cref="UiSessionTests"/>).
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gclo.exe under test.</summary>
|
||||
public string ExePath { get; }
|
||||
|
||||
/// <summary>The temp directory the app sees as its data root (via GCLO_DATA_DIR).</summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>The UIA3 automation used for every lookup and pattern call.</summary>
|
||||
public UIA3Automation Automation { get; }
|
||||
|
||||
/// <summary>The launched application (never null after construction).</summary>
|
||||
public Application App { get; }
|
||||
|
||||
/// <summary>The app's main window, resolved once at launch.</summary>
|
||||
public Window MainWindow { get; }
|
||||
|
||||
// ---------------------------------------------------------------- lookups
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first element matching <paramref name="condition"/> 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.
|
||||
/// </summary>
|
||||
public AutomationElement? FindInApp(Func<ConditionFactory, ConditionBase> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retries <paramref name="lookup"/> until it returns an element, throwing a
|
||||
/// <see cref="TimeoutException"/> naming <paramref name="description"/> when the
|
||||
/// timeout (default 10s) elapses first. Exceptions inside the lookup (stale
|
||||
/// elements mid-transition) count as "not found yet" and are retried.
|
||||
/// </summary>
|
||||
public AutomationElement WaitFor(
|
||||
Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lookup);
|
||||
TimeSpan effective = timeout ?? DefaultTimeout;
|
||||
RetryResult<AutomationElement?> 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})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retries until <paramref name="lookup"/> returns null (the element left the
|
||||
/// tree), throwing a <see cref="TimeoutException"/> naming
|
||||
/// <paramref name="description"/> when it is still present at the timeout.
|
||||
/// </summary>
|
||||
public static void WaitUntilGone(
|
||||
Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lookup);
|
||||
TimeSpan effective = timeout ?? DefaultTimeout;
|
||||
RetryResult<bool> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for the element carrying <paramref name="automationId"/>.</summary>
|
||||
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
|
||||
=> WaitFor(
|
||||
() => FindInApp(cf => cf.ByAutomationId(automationId)),
|
||||
$"element '{automationId}'",
|
||||
timeout);
|
||||
|
||||
/// <summary>Waits until no element carries <paramref name="automationId"/> anymore.</summary>
|
||||
public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
|
||||
=> WaitUntilGone(
|
||||
() => FindInApp(cf => cf.ByAutomationId(automationId)),
|
||||
$"element '{automationId}'",
|
||||
timeout);
|
||||
|
||||
/// <summary>Waits for a text element whose name is exactly <paramref name="text"/>.</summary>
|
||||
public AutomationElement WaitForText(string text, TimeSpan? timeout = null)
|
||||
=> WaitFor(
|
||||
() => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
|
||||
$"text '{text}'",
|
||||
timeout);
|
||||
|
||||
/// <summary>Waits until no text element named <paramref name="text"/> remains.</summary>
|
||||
public void WaitForTextGone(string text, TimeSpan? timeout = null)
|
||||
=> WaitUntilGone(
|
||||
() => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
|
||||
$"text '{text}'",
|
||||
timeout);
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
/// <summary>
|
||||
/// Opens the menu bar item named <paramref name="menuBarItemName"/> (e.g. "File")
|
||||
/// and invokes the flyout item carrying <paramref name="itemAutomationId"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void InvokeNavItem(string automationId)
|
||||
=> InvokeElement(WaitForElement(automationId));
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the XAML button named <paramref name="name"/>. 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort variant of <see cref="ClickButton"/> for <c>finally</c> 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="text"/> into <paramref name="element"/>: the UIA Value
|
||||
/// pattern when available and writable (works without window focus), otherwise
|
||||
/// focus plus real keystrokes via <see cref="Keyboard"/>.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Invoke pattern first, SelectionItem second, mouse click as last resort.</summary>
|
||||
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<Window?> 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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FlaUI.Core.AutomationElements;
|
||||
|
||||
namespace gclo.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AppSession"/>).
|
||||
/// Dialog tests close what they open on the happy path and fall back to a
|
||||
/// best-effort <see cref="AppSession.TryClickButton"/> in <c>finally</c>, so one
|
||||
/// failure cannot strand a dialog in front of the next test.
|
||||
/// </summary>
|
||||
[Collection(nameof(UiSessionTests))]
|
||||
public sealed partial class SmokeTests : UiTestBase
|
||||
{
|
||||
/// <summary>Small pause after a button invoke whose handler runs behind a dialog deferral.</summary>
|
||||
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); // "(<short git hash>)"
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace gclo.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// The single xunit collection every UI test belongs to (referenced via
|
||||
/// <c>nameof(UiSessionTests)</c>). All UI tests share one launched gclo instance
|
||||
/// (the <see cref="AppSession"/> collection fixture) and run sequentially
|
||||
/// (<c>DisableParallelization</c>), so two tests never drive two overlapping app
|
||||
/// instances or interleave input on the same one.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(UiSessionTests), DisableParallelization = true)]
|
||||
public sealed class UiSessionTests : ICollectionFixture<AppSession>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for UI tests: holds the shared <see cref="AppSession"/> so test
|
||||
/// bodies read as session-relative steps. Derived classes must carry
|
||||
/// <c>[Collection(nameof(UiSessionTests))]</c> — xunit does not inherit it.
|
||||
/// </summary>
|
||||
public abstract class UiTestBase
|
||||
{
|
||||
/// <summary>Captures the collection fixture xunit injects per test class.</summary>
|
||||
protected UiTestBase(AppSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
Session = session;
|
||||
}
|
||||
|
||||
/// <summary>The app instance shared by every test in the collection.</summary>
|
||||
protected AppSession Session { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- Out-of-process UI smoke tests: FlaUI drives the built gclo.exe over UI
|
||||
Automation, so this project deliberately has NO ProjectReference to the app.
|
||||
Build the app first (dotnet build gclo/gclo.csproj -p:Platform=x64
|
||||
-p:WindowsPackageType=None) or point GCLO_UITEST_EXE at an exe to test.
|
||||
CI builds this project but only runs gclo.Engine.Tests; the UI suite is run
|
||||
locally where an interactive desktop is available. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -5,7 +5,8 @@ using gclo.Engine;
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their
|
||||
/// Persists account profiles as JSON at accounts.json under
|
||||
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\gclo) and their
|
||||
/// tokens in an <see cref="ITokenVault"/>; the file holds metadata only and never a
|
||||
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
|
||||
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
|
||||
@@ -22,17 +23,16 @@ public sealed class AccountsStore
|
||||
|
||||
/// <summary>
|
||||
/// Loads existing accounts from <paramref name="directory"/> (default:
|
||||
/// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt
|
||||
/// or unreadable one is logged (pass the app's <paramref name="log"/> to surface
|
||||
/// that) and treated as empty rather than blocking startup.
|
||||
/// <see cref="GcloPaths.DataRoot"/>). A missing file simply means no accounts yet;
|
||||
/// a corrupt or unreadable one is logged (pass the app's <paramref name="log"/> to
|
||||
/// surface that) and treated as empty rather than blocking startup.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ using System.Text.Json.Serialization;
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="GcloPaths.DataRoot"/> (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.
|
||||
/// </summary>
|
||||
public sealed class AppSettings
|
||||
{
|
||||
@@ -23,10 +24,7 @@ public sealed class AppSettings
|
||||
/// <summary>App theme: "System", "Light", or "Dark". Unknown values fall back to "System".</summary>
|
||||
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");
|
||||
|
||||
/// <summary>
|
||||
/// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class GcloPaths
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string DataRoot =>
|
||||
Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir
|
||||
? dir
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
<Project Path="gclo.Cli/gclo.Cli.csproj" />
|
||||
<Project Path="gclo.Engine.Tests/gclo.Engine.Tests.csproj" />
|
||||
<Project Path="gclo.Engine/gclo.Engine.csproj" />
|
||||
<Project Path="gclo.UiTests/gclo.UiTests.csproj" />
|
||||
<Project Path="gclo.ViewModels/gclo.ViewModels.csproj" />
|
||||
<Project Path="gclo/gclo.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
x:Name="VersionText"
|
||||
AutomationProperties.AutomationId="AboutVersionText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 1 of 4: Identity" />
|
||||
<TextBox
|
||||
AutomationProperties.AutomationId="WizardNameBox"
|
||||
Header="Account name"
|
||||
PlaceholderText="e.g. Work – Acme"
|
||||
Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
@@ -53,6 +54,7 @@
|
||||
Text="Step 2 of 4: Access token" />
|
||||
<PasswordBox
|
||||
x:Name="TokenBox"
|
||||
AutomationProperties.AutomationId="WizardTokenBox"
|
||||
Header="Personal access token"
|
||||
PlaceholderText="ghp_…"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
@@ -94,6 +96,7 @@
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 3 of 4: Organization" />
|
||||
<ComboBox
|
||||
AutomationProperties.AutomationId="WizardOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
IsEditable="True"
|
||||
@@ -116,6 +119,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
AutomationProperties.AutomationId="WizardTargetBox"
|
||||
Header="Target folder"
|
||||
PlaceholderText="C:\src\my-org"
|
||||
Text="{x:Bind ViewModel.TargetRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
+30
-1
@@ -1,3 +1,4 @@
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
@@ -10,6 +11,12 @@ namespace gclo
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
/// <summary>
|
||||
/// Where the unhandled-exception net writes; set by MainWindow once the shared
|
||||
/// activity log exists. Null only during the first instants of startup.
|
||||
/// </summary>
|
||||
internal static IActivityLog? CrashLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// Invoked when the application is launched. The branded splash is an overlay
|
||||
/// inside <see cref="MainWindow"/> (see <see cref="SplashOverlay"/> for why a
|
||||
/// separate splash window is a UIA crash hazard).
|
||||
/// </summary>
|
||||
/// <param name="args">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ShowAsync"/>, 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.
|
||||
/// </summary>
|
||||
internal static class DialogGuard
|
||||
{
|
||||
private static bool _dialogOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Shows <paramref name="dialog"/> unless another dialog is on screen.
|
||||
/// Returns the dialog result, or null when the request was ignored —
|
||||
/// callers treat null like a dismissal.
|
||||
/// </summary>
|
||||
public static async Task<ContentDialogResult?> ShowAsync(ContentDialog dialog)
|
||||
{
|
||||
if (_dialogOpen)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_dialogOpen = true;
|
||||
try
|
||||
{
|
||||
return await dialog.ShowAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-7
@@ -21,18 +21,18 @@
|
||||
|
||||
<MenuBar Grid.Row="0">
|
||||
<MenuBarItem Title="File" AccessKey="F">
|
||||
<MenuFlyoutItem Text="Settings…" AccessKey="S" Click="SettingsMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Settings…" AccessKey="S" AutomationProperties.AutomationId="MenuSettings" Click="SettingsMenuItem_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem Text="Exit" AccessKey="X" Click="ExitMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Exit" AccessKey="X" AutomationProperties.AutomationId="MenuExit" Click="ExitMenuItem_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="View" AccessKey="V">
|
||||
<MenuFlyoutItem Text="Activity log…" AccessKey="A" Click="ActivityLogMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Activity log…" AccessKey="A" AutomationProperties.AutomationId="MenuActivityLog" Click="ActivityLogMenuItem_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="Help" AccessKey="H">
|
||||
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" Click="CheckForUpdatesMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem Text="About gclo…" AccessKey="A" Click="AboutMenuItem_Click">
|
||||
<MenuFlyoutItem Text="About gclo…" AccessKey="A" AutomationProperties.AutomationId="MenuAbout" Click="AboutMenuItem_Click">
|
||||
<MenuFlyoutItem.KeyboardAccelerators>
|
||||
<KeyboardAccelerator Key="F1" />
|
||||
</MenuFlyoutItem.KeyboardAccelerators>
|
||||
@@ -45,6 +45,7 @@
|
||||
Content hosts the selected workspace's cached WorkspacePage. -->
|
||||
<NavigationView
|
||||
x:Name="WorkspaceNav"
|
||||
AutomationProperties.AutomationId="WorkspaceNav"
|
||||
Grid.Row="1"
|
||||
PaneDisplayMode="Left"
|
||||
IsBackButtonVisible="Collapsed"
|
||||
@@ -52,7 +53,10 @@
|
||||
OpenPaneLength="240"
|
||||
SelectionChanged="WorkspaceNav_SelectionChanged">
|
||||
<NavigationView.MenuItems>
|
||||
<NavigationViewItem x:Name="QuickSyncNavItem" Content="Quick Sync">
|
||||
<NavigationViewItem
|
||||
x:Name="QuickSyncNavItem"
|
||||
AutomationProperties.AutomationId="NavQuickSync"
|
||||
Content="Quick Sync">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
@@ -62,6 +66,7 @@
|
||||
<NavigationView.FooterMenuItems>
|
||||
<NavigationViewItem
|
||||
x:Name="SyncAllNavItem"
|
||||
AutomationProperties.AutomationId="NavSyncAll"
|
||||
Content="Sync all"
|
||||
Tag="syncall"
|
||||
IsEnabled="False">
|
||||
@@ -69,12 +74,22 @@
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="Add account" Tag="add">
|
||||
<NavigationViewItem
|
||||
AutomationProperties.AutomationId="NavAddAccount"
|
||||
Content="Add account"
|
||||
Tag="add">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
</NavigationView.FooterMenuItems>
|
||||
</NavigationView>
|
||||
|
||||
<!-- Branded startup splash: covers the whole window until MainWindow dismisses
|
||||
it (fade + removal). An overlay, not a second Window — see SplashOverlay. -->
|
||||
<local:SplashOverlay
|
||||
x:Name="StartupSplash"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2" />
|
||||
</Grid>
|
||||
</Window>
|
||||
+23
-11
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Minimum splash display, so a fast startup never shows a flicker.</summary>
|
||||
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")]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UserControl
|
||||
x:Class="gclo.SplashOverlay"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<!-- Brand splash, rendered as a full-window overlay INSIDE the main window: a
|
||||
separate splash Window is a UIA crash hazard (assistive tech or UI automation
|
||||
querying it mid-close takes down the process with a stowed exception), while
|
||||
an overlay is ordinary XAML churn. Deliberately single-theme: the gradient and
|
||||
white type ARE the brand look in both OS themes; the root requests Dark so
|
||||
theme-resource-driven control parts (the ProgressBar) match the palette. -->
|
||||
<Grid
|
||||
x:Name="SplashRoot"
|
||||
RequestedTheme="Dark"
|
||||
AutomationProperties.Name="gclo is starting">
|
||||
<Grid.Background>
|
||||
<!-- The app icon's palette, run diagonally for a little depth. -->
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Offset="0" Color="#3B4CCA" />
|
||||
<GradientStop Offset="1" Color="#2A2F8F" />
|
||||
</LinearGradientBrush>
|
||||
</Grid.Background>
|
||||
|
||||
<Grid.Resources>
|
||||
<Storyboard x:Name="FadeOut">
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetName="SplashRoot"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To="0"
|
||||
Duration="0:0:0.25" />
|
||||
</Storyboard>
|
||||
</Grid.Resources>
|
||||
|
||||
<StackPanel
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<FontIcon
|
||||
Glyph=""
|
||||
FontSize="56"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,16" />
|
||||
<TextBlock
|
||||
Text="gclo"
|
||||
FontFamily="Segoe UI Variable Display"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="40"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock
|
||||
Text="Git Clone Large Organizations"
|
||||
FontSize="14"
|
||||
Foreground="White"
|
||||
Opacity="0.8"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0" />
|
||||
<TextBlock
|
||||
x:Name="VersionText"
|
||||
FontSize="12"
|
||||
Foreground="White"
|
||||
Opacity="0.6"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Thin activity strip: white bar over a 20% white track; the track-height
|
||||
resource is overridden so the track matches the 3px bar. -->
|
||||
<ProgressBar
|
||||
IsIndeterminate="True"
|
||||
Height="3"
|
||||
MinHeight="3"
|
||||
Foreground="White"
|
||||
Background="#33FFFFFF"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,20">
|
||||
<ProgressBar.Resources>
|
||||
<x:Double x:Key="ProgressBarTrackHeight">3</x:Double>
|
||||
</ProgressBar.Resources>
|
||||
</ProgressBar>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Branded startup splash rendered as a full-window overlay inside
|
||||
/// <see cref="MainWindow"/>. 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.
|
||||
/// </summary>
|
||||
public sealed partial class SplashOverlay : UserControl
|
||||
{
|
||||
private bool _dismissed;
|
||||
|
||||
/// <summary>Builds the overlay and stamps the build identity line.</summary>
|
||||
public SplashOverlay()
|
||||
{
|
||||
InitializeComponent();
|
||||
VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fades the overlay out and removes it from its parent panel. Safe to call
|
||||
/// more than once; only the first call does anything.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
<StackPanel Spacing="12">
|
||||
<PasswordBox
|
||||
x:Name="TokenBox"
|
||||
AutomationProperties.AutomationId="ConnectTokenBox"
|
||||
Header="Personal access token"
|
||||
PlaceholderText="ghp_…"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
@@ -55,6 +56,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox
|
||||
Grid.Column="0"
|
||||
AutomationProperties.AutomationId="ConnectOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
IsEditable="True"
|
||||
@@ -77,11 +79,13 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
AutomationProperties.AutomationId="ConnectTargetBox"
|
||||
Header="Target folder"
|
||||
PlaceholderText="C:\src\my-org"
|
||||
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
AutomationProperties.AutomationId="ConnectBrowseButton"
|
||||
VerticalAlignment="Bottom"
|
||||
Content="Browse…"
|
||||
Click="BrowseButton_Click" />
|
||||
@@ -103,6 +107,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Button
|
||||
MinWidth="140"
|
||||
AutomationProperties.AutomationId="LoadReposButton"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Content="Load repositories"
|
||||
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
|
||||
@@ -175,6 +180,7 @@
|
||||
<HyperlinkButton
|
||||
Padding="4,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AutomationId="ChipEditButton"
|
||||
AutomationProperties.Name="Edit connection"
|
||||
Content="Edit"
|
||||
Click="EditButton_Click">
|
||||
@@ -286,6 +292,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<SelectorBar
|
||||
x:Name="FilterSelectorBar"
|
||||
AutomationProperties.AutomationId="RepoFilterBar"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
SelectionChanged="FilterSelectorBar_SelectionChanged">
|
||||
@@ -306,6 +313,7 @@
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}">
|
||||
<Button
|
||||
x:Name="OptionsButton"
|
||||
AutomationProperties.AutomationId="SyncOptionsButton"
|
||||
AutomationProperties.Name="Sync options"
|
||||
ToolTipService.ToolTip="Sync options">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
@@ -340,6 +348,7 @@
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button
|
||||
AutomationProperties.AutomationId="RefreshButton"
|
||||
Content="Refresh"
|
||||
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
|
||||
<Button.KeyboardAccelerators>
|
||||
@@ -348,6 +357,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
MinWidth="120"
|
||||
AutomationProperties.AutomationId="SyncButton"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Content="{x:Bind ViewModel.SyncButtonLabel, Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}">
|
||||
@@ -371,6 +381,7 @@
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind local:StatusFormat.RunProgressText(ViewModel.CompletedCount, ViewModel.TotalCount), Mode=OneWay}" />
|
||||
<Button
|
||||
AutomationProperties.AutomationId="CancelSyncButton"
|
||||
Content="Cancel"
|
||||
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}">
|
||||
<Button.KeyboardAccelerators>
|
||||
@@ -432,6 +443,7 @@
|
||||
<!-- Run results: opens when a run ends, user-dismissable (two-way IsOpen). -->
|
||||
<InfoBar
|
||||
Grid.Row="2"
|
||||
AutomationProperties.AutomationId="ResultInfoBar"
|
||||
Margin="0,8,0,0"
|
||||
IsOpen="{x:Bind ViewModel.ResultOpen, Mode=TwoWay}"
|
||||
Severity="{x:Bind local:StatusFormat.SeverityFor(ViewModel.ResultKind), Mode=OneWay}"
|
||||
@@ -555,6 +567,7 @@
|
||||
via ElementName (x:Bind inside the template is scoped to the row item). -->
|
||||
<ListView
|
||||
x:Name="RepoListView"
|
||||
AutomationProperties.AutomationId="RepoListView"
|
||||
Grid.Row="4"
|
||||
DataContext="{x:Bind ViewModel, Mode=OneTime}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredRepos, Mode=OneTime}"
|
||||
@@ -673,6 +686,7 @@
|
||||
the tree (with its name) so announcements keep working in both states. -->
|
||||
<TextBlock
|
||||
x:Name="StatusTextBlock"
|
||||
AutomationProperties.AutomationId="StatusTextBlock"
|
||||
Grid.Row="1"
|
||||
AutomationProperties.LiveSetting="Polite"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
|
||||
@@ -206,8 +206,8 @@ namespace gclo
|
||||
}
|
||||
|
||||
var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = XamlRoot };
|
||||
await dialog.ShowAsync();
|
||||
return dialog.Result;
|
||||
await DialogGuard.ShowAsync(dialog);
|
||||
return dialog.Result; // blocked by another open dialog reads as dismissed
|
||||
}
|
||||
|
||||
// PasswordBox does not support reliable two-way x:Bind on Password;
|
||||
|
||||
Reference in new issue
Block a user