## What Brings AppKit into conformance with [KofTwentyTwo/standards](https://github.com/KofTwentyTwo/standards). Reviewable commit by commit: | Commit | Change | | --- | --- | | build | Quality gate from `configs/dotnet` (analyzers incl. Meziantou, warnings as errors), `packages.lock.json` per project, locked-mode restore | | style | Kingsrook layout and LF line endings, produced mechanically by `dotnet format` (formatting only) | | docs | SPDX license header on every C# file | | refactor | Real code changes: analyzer findings fixed (one type per file, regex timeouts, async void, …) and header comments on every type and member | | ci | Old workflows replaced by callers of the shared standards workflows (PR gates, security, CodeQL, .NET CI, NuGet release with SBOM + attestation + trusted publishing), Scorecard | | docs | README, CONTRIBUTING (GitHub Flow, DCO, signing), SECURITY, CODEOWNERS, templates, Dependabot, threat model, ADRs, RELEASING | Also includes `dev`'s CI fix, so it supersedes #1; the `dev` branch goes away under GitHub Flow. ## Why AppKit is the first Product repository to adopt the standards. Static conformance before: 16 FAIL; after: 0 FAIL, 0 WARN (the remaining UNKNOWN rows are GitHub settings and rulesets, applied separately). ## How tested Locally with `CI=true` (lock files enforced): locked restore x64 and ARM64; zero-warning x64 build; 115 unit tests with 100% line coverage of `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`; 12 FlaUI UI tests (WinUI + WPF samples); `dotnet format --verify-no-changes --severity warn` clean; pack produces 4 packages + symbols; actionlint, zizmor (pedantic), gitleaks clean. The UI tests caught a regression introduced while fixing MA0147 (the WPF splash stopped dismissing because no UI context existed yet); fixed by starting the countdown through the dispatcher in both packages. ## Notes - `ci.yml` and `release.yml` are pinned to KofTwentyTwo/standards#3's head commit; they are repinned after that PR merges. - This pull request's new check names become required checks once the repository's rulesets are updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: James Maes <james@kof22.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
11 KiB
C#
325 lines
11 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
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.Patterns;
|
|
using FlaUI.Core.Tools;
|
|
using FlaUI.UIA3;
|
|
|
|
|
|
namespace KofTwentyTwo.AppKit.UiTests;
|
|
|
|
/// <summary>Which sample app a test drives.</summary>
|
|
public enum SampleKind
|
|
{
|
|
WinUI,
|
|
Wpf,
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// One launched sample app plus the UIA3 automation that drives it. Each session gets a
|
|
/// fresh temp directory passed as APPKIT_SAMPLE_DATA_DIR, so tests start with no
|
|
/// settings or logs and never touch the real profile. Unless a test asks for the
|
|
/// splash, the session pre-writes settings that skip it and the startup update check.
|
|
/// </summary>
|
|
public sealed class AppSession : IDisposable
|
|
{
|
|
private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
|
|
private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30);
|
|
private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250);
|
|
|
|
|
|
|
|
/// <summary>Launches the sample against a fresh temporary data folder and waits for its main window.</summary>
|
|
public AppSession(SampleKind kind, bool showSplash = false)
|
|
{
|
|
ExePath = ResolveExePath(kind);
|
|
DataDirectory = Path.Combine(Path.GetTempPath(), "appkit-uitests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(DataDirectory);
|
|
File.WriteAllText(
|
|
Path.Combine(DataDirectory, "settings.json"),
|
|
$$"""{ "ShowSplashScreen": {{(showSplash ? "true" : "false")}}, "CheckForUpdatesOnStartup": false }""");
|
|
|
|
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
|
|
startInfo.Environment["APPKIT_SAMPLE_DATA_DIR"] = DataDirectory;
|
|
|
|
Automation = new UIA3Automation();
|
|
try
|
|
{
|
|
App = Application.Launch(startInfo);
|
|
MainWindow = WaitForMainWindow();
|
|
}
|
|
catch
|
|
{
|
|
Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public string ExePath { get; }
|
|
|
|
public string DataDirectory { get; }
|
|
|
|
public UIA3Automation Automation { get; }
|
|
|
|
public Application App { get; }
|
|
|
|
public Window MainWindow { get; }
|
|
|
|
|
|
|
|
/// <summary>Text of every log file the app wrote so far.</summary>
|
|
public string ReadLogs()
|
|
{
|
|
string logs = Path.Combine(DataDirectory, "logs");
|
|
return Directory.Exists(logs)
|
|
? string.Concat(Directory.GetFiles(logs, "*.log").Select(ReadShared))
|
|
: "";
|
|
}
|
|
|
|
|
|
|
|
/// <summary>The settings file as the app last saved it.</summary>
|
|
public string ReadSettings() => ReadShared(Path.Combine(DataDirectory, "settings.json"));
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Finds the first match in the main window or any other top-level window of the
|
|
/// app's process (WinUI menu flyouts and secondary windows are separate HWNDs).
|
|
/// </summary>
|
|
public AutomationElement? FindInApp(Func<ConditionFactory, ConditionBase> condition)
|
|
=> MainWindow.FindFirstDescendant(condition)
|
|
?? TopLevelWindows().Where(w => !w.Equals(MainWindow)).Select(w => w.FindFirstDescendant(condition)).FirstOrDefault(found => found is not null);
|
|
|
|
|
|
|
|
/// <summary>Top-level windows owned by the app's process.</summary>
|
|
public AutomationElement[] TopLevelWindows()
|
|
=> Automation.GetDesktop().FindAllChildren(cf => cf.ByProcessId(App.ProcessId));
|
|
|
|
|
|
|
|
/// <summary>Retries the lookup until it returns an element, or fails naming what was awaited.</summary>
|
|
public AutomationElement WaitFor(Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
|
|
{
|
|
TimeSpan effective = timeout ?? s_defaultTimeout;
|
|
RetryResult<AutomationElement?> result = Retry.WhileNull(lookup, timeout: effective, interval: s_pollInterval, ignoreException: true);
|
|
return result.Result
|
|
?? throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description}. (app exited: {App.HasExited})");
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Retries until the lookup returns nothing, or fails naming what was expected to disappear.</summary>
|
|
public static void WaitUntilGone(Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
|
|
{
|
|
TimeSpan effective = timeout ?? s_defaultTimeout;
|
|
if(!Retry.WhileTrue(() => lookup() is not null, timeout: effective, interval: s_pollInterval, ignoreException: true).Success)
|
|
{
|
|
throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away.");
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Waits for the element with the given automation id in any of the app's windows.</summary>
|
|
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
|
|
=> WaitFor(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
|
|
|
|
|
|
|
|
/// <summary>Waits until no element with the given automation id remains.</summary>
|
|
public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
|
|
=> WaitUntilGone(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Opens the top-level menu named <paramref name="menuName"/> and invokes the item
|
|
/// <paramref name="itemAutomationId"/>. A freshly started app can swallow the first
|
|
/// expand (its flyout opens before the menu is ready), so the menu is re-opened
|
|
/// until the item appears.
|
|
/// </summary>
|
|
public void InvokeMenuItem(string menuName, string itemAutomationId)
|
|
{
|
|
AutomationElement menu = WaitFor(
|
|
() => MainWindow.FindFirstDescendant(cf => cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuName))),
|
|
$"menu '{menuName}'");
|
|
AutomationElement? item = null;
|
|
for(int attempt = 0; attempt < 4 && item is null; attempt++)
|
|
{
|
|
if(menu.Patterns.ExpandCollapse.TryGetPattern(out IExpandCollapsePattern? expandCollapse))
|
|
{
|
|
expandCollapse.Expand();
|
|
}
|
|
else
|
|
{
|
|
Invoke(menu);
|
|
}
|
|
Wait.UntilInputIsProcessed();
|
|
item = Retry.WhileNull(() => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), timeout: TimeSpan.FromSeconds(3), interval: s_pollInterval, ignoreException: true).Result;
|
|
}
|
|
Invoke(item ?? throw new TimeoutException($"Menu item '{itemAutomationId}' never appeared under '{menuName}'. (app exited: {App.HasExited})"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Invokes the button named <paramref name="name"/> (XAML buttons only, not caption buttons).</summary>
|
|
public void ClickButton(string name)
|
|
=> Invoke(WaitFor(
|
|
() => FindInApp(cf => cf.ByControlType(ControlType.Button).And(cf.ByName(name)).And(cf.ByClassName("Button"))),
|
|
$"button '{name}'"));
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Clicks with the real mouse. Needed where the handler throws: WinUI hands an
|
|
/// exception raised during a UIA Invoke back to the automation client instead of
|
|
/// raising Application.UnhandledException, which is what a user's click does.
|
|
/// </summary>
|
|
public void MouseClickElement(string automationId)
|
|
{
|
|
AutomationElement element = WaitForElement(automationId);
|
|
MainWindow.SetForeground();
|
|
element.Click();
|
|
Wait.UntilInputIsProcessed();
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Invokes an element through UI Automation, falling back to a mouse click.</summary>
|
|
private static void Invoke(AutomationElement element)
|
|
{
|
|
if(element.Patterns.Invoke.TryGetPattern(out IInvokePattern? invoke))
|
|
{
|
|
invoke.Invoke();
|
|
}
|
|
else
|
|
{
|
|
element.Click();
|
|
}
|
|
Wait.UntilInputIsProcessed();
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Waits for the app's main window, failing fast if the process exits.</summary>
|
|
private Window WaitForMainWindow()
|
|
{
|
|
RetryResult<Window?> result = Retry.WhileNull(
|
|
() =>
|
|
{
|
|
if(App.HasExited)
|
|
{
|
|
throw new InvalidOperationException($"The app exited while starting (exit code {App.ExitCode}). Exe: {ExePath}");
|
|
}
|
|
try
|
|
{
|
|
return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500));
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
},
|
|
timeout: s_launchTimeout,
|
|
interval: s_pollInterval);
|
|
return result.Result ?? throw new TimeoutException($"The main window did not appear within {s_launchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Reads a file the app may still be writing, without locking it.</summary>
|
|
private static string ReadShared(string path)
|
|
{
|
|
if(!File.Exists(path))
|
|
{
|
|
return "";
|
|
}
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
return new StreamReader(stream).ReadToEnd();
|
|
}
|
|
|
|
|
|
|
|
/// <summary>The exe to drive: the env override, else the repo's Debug output.</summary>
|
|
private static string ResolveExePath(SampleKind kind)
|
|
{
|
|
string variable = kind == SampleKind.WinUI ? "APPKIT_UITEST_WINUI_EXE" : "APPKIT_UITEST_WPF_EXE";
|
|
if(Environment.GetEnvironmentVariable(variable) is { Length: > 0 } fromEnv)
|
|
{
|
|
return File.Exists(fromEnv) ? fromEnv : throw new FileNotFoundException($"{variable} points at '{fromEnv}', which does not exist.", fromEnv);
|
|
}
|
|
|
|
for(DirectoryInfo? current = new(AppContext.BaseDirectory); current is not null; current = current.Parent)
|
|
{
|
|
if(!File.Exists(Path.Combine(current.FullName, "AppKit.slnx")))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
(string project, string exe) = kind == SampleKind.WinUI
|
|
? ("AppKit.Sample.WinUI", "AppKitSample.WinUI.exe")
|
|
: ("AppKit.Sample.Wpf", "AppKitSample.Wpf.exe");
|
|
string bin = Path.Combine(current.FullName, "samples", project, "bin");
|
|
string? found = Directory.Exists(bin)
|
|
? Directory.EnumerateFiles(bin, exe, SearchOption.AllDirectories)
|
|
.Where(path => path.Contains($"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
|
.OrderByDescending(File.GetLastWriteTimeUtc)
|
|
.FirstOrDefault()
|
|
: null;
|
|
return found ?? throw new FileNotFoundException($"{exe} not found under '{bin}'. Build first: dotnet build AppKit.slnx -p:Platform=x64");
|
|
}
|
|
|
|
throw new FileNotFoundException($"No AppKit.slnx above '{AppContext.BaseDirectory}'. Set {variable}.");
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Closes the app (killing it if needed), then disposes the automation and deletes the data folder.</summary>
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
App?.Close();
|
|
}
|
|
catch
|
|
{
|
|
// Already gone; the kill below double-checks.
|
|
}
|
|
try
|
|
{
|
|
if(App is { HasExited: false })
|
|
{
|
|
App.Kill();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best effort.
|
|
}
|
|
App?.Dispose();
|
|
Automation.Dispose();
|
|
try
|
|
{
|
|
Directory.Delete(DataDirectory, recursive: true);
|
|
}
|
|
catch
|
|
{
|
|
// A log handle can linger briefly after exit; the temp cleaner gets it.
|
|
}
|
|
}
|
|
}
|