Browse Source

Merge pull request #331 from ionite34/ui-tests

Add UI Tests
pull/240/head
Ionite 1 year ago committed by GitHub
parent
commit
f6c00776be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 27
      .github/workflows/test-ui.yml
  2. 20
      StabilityMatrix.Avalonia/App.axaml.cs
  3. 12
      StabilityMatrix.Avalonia/Program.cs
  4. 1
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  5. 16
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  6. 9
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  7. 90
      StabilityMatrix.Core/Helper/Compat.cs
  8. 4
      StabilityMatrix.Core/Python/PyVenvRunner.cs
  9. 3
      StabilityMatrix.Core/StabilityMatrix.Core.csproj
  10. 12
      StabilityMatrix.UITests/Attributes/TestPriorityAttribute.cs
  11. 17
      StabilityMatrix.UITests/Extensions/VisualExtensions.cs
  12. 73
      StabilityMatrix.UITests/Extensions/WindowExtensions.cs
  13. 149
      StabilityMatrix.UITests/MainWindowTests.cs
  14. 29
      StabilityMatrix.UITests/ModuleInit.cs
  15. 57
      StabilityMatrix.UITests/PriorityOrderer.cs
  16. 39
      StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt
  17. BIN
      StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindow_ShouldOpen.verified.png
  18. 451
      StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindow_ShouldOpen.verified.txt
  19. 37
      StabilityMatrix.UITests/StabilityMatrix.UITests.csproj
  20. 40
      StabilityMatrix.UITests/TempDirFixture.cs
  21. 78
      StabilityMatrix.UITests/TestAppBuilder.cs
  22. 6
      StabilityMatrix.UITests/Usings.cs
  23. 15
      StabilityMatrix.UITests/VerifyConfig.cs
  24. 51
      StabilityMatrix.UITests/WaitHelper.cs
  25. 6
      StabilityMatrix.sln

27
.github/workflows/test-ui.yml

@ -0,0 +1,27 @@
name: UI Tests
on:
workflow_dispatch:
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.repository == 'LykosAI/StabilityMatrix' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
- name: Install dependencies
run: dotnet restore
- name: Test
run: dotnet test StabilityMatrix.UITests

20
StabilityMatrix.Avalonia/App.axaml.cs

@ -70,13 +70,18 @@ public sealed class App : Application
public static IServiceProvider? Services { get; private set; }
[NotNull]
public static Visual? VisualRoot { get; private set; }
public static Visual? VisualRoot { get; internal set; }
public static TopLevel TopLevel => TopLevel.GetTopLevel(VisualRoot)!;
internal static bool IsHeadlessMode =>
TopLevel.TryGetPlatformHandle()?.HandleDescriptor is null or "STUB";
[NotNull]
public static IStorageProvider? StorageProvider { get; private set; }
public static IStorageProvider? StorageProvider { get; internal set; }
[NotNull]
public static IClipboard? Clipboard { get; private set; }
public static IClipboard? Clipboard { get; internal set; }
// ReSharper disable once MemberCanBePrivate.Global
[NotNull]
@ -86,6 +91,12 @@ public sealed class App : Application
public IClassicDesktopStyleApplicationLifetime? DesktopLifetime =>
ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
/// <summary>
/// Called before <see cref="Services"/> is built.
/// Can be used by UI tests to override services.
/// </summary>
internal static event EventHandler<IServiceCollection>? BeforeBuildServiceProvider;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@ -214,6 +225,9 @@ public sealed class App : Application
private static void ConfigureServiceProvider()
{
var services = ConfigureServices();
BeforeBuildServiceProvider?.Invoke(null, services);
Services = services.BuildServiceProvider();
var settingsManager = Services.GetRequiredService<ISettingsManager>();

12
StabilityMatrix.Avalonia/Program.cs

@ -83,13 +83,21 @@ public class Program
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
/// <summary>
/// Called in <see cref="BuildAvaloniaApp"/> and UI tests to setup static configurations
/// </summary>
internal static void SetupAvaloniaApp()
{
IconProvider.Current.Register<FontAwesomeIconProvider>();
// Use our custom image loader for custom local load error handling
ImageLoader.AsyncImageLoader.Dispose();
ImageLoader.AsyncImageLoader = new FallbackRamCachedWebImageLoader();
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
{
SetupAvaloniaApp();
var app = AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();

1
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -16,6 +16,7 @@
<ItemGroup>
<InternalsVisibleTo Include="StabilityMatrix.Tests" />
<InternalsVisibleTo Include="StabilityMatrix.UITests" />
</ItemGroup>
<ItemGroup>

16
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -124,24 +124,30 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
if (UpdateInfo is null)
return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.ChangelogUrl);
}
internal async Task<string> GetReleaseNotes(string changelogUrl)
{
using var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(UpdateInfo.ChangelogUrl);
var response = await client.GetAsync(changelogUrl);
if (response.IsSuccessStatusCode)
{
var changelog = await response.Content.ReadAsStringAsync();
// Formatting for new changelog format
// https://keepachangelog.com/en/1.1.0/
if (UpdateInfo.ChangelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
ReleaseNotes =
FormatChangelog(changelog, Compat.AppVersion)
return FormatChangelog(changelog, Compat.AppVersion)
?? "## Unable to format release notes";
}
return changelog;
}
else
{
ReleaseNotes = "## Unable to load release notes";
return "## Unable to load release notes";
}
}

9
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -102,7 +102,10 @@ public partial class MainWindowViewModel : ViewModelBase
// Index checkpoints if we dont have
Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget();
PreloadPages();
if (!App.IsHeadlessMode)
{
PreloadPages();
}
Program.StartupTimer.Stop();
var startupTime = CodeTimer.FormatTime(Program.StartupTimer.Elapsed);
@ -128,7 +131,7 @@ public partial class MainWindowViewModel : ViewModelBase
EventManager.Instance.OnTeachingTooltipNeeded();
};
await dialog.ShowAsync();
await dialog.ShowAsync(App.TopLevel);
}
}
@ -239,7 +242,7 @@ public partial class MainWindowViewModel : ViewModelBase
Content = new SelectDataDirectoryDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
var result = await dialog.ShowAsync(App.TopLevel);
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()

90
StabilityMatrix.Core/Helper/Compat.cs

@ -15,38 +15,46 @@ namespace StabilityMatrix.Core.Helper;
public static class Compat
{
private const string AppName = "StabilityMatrix";
public static SemVersion AppVersion { get; set; }
// OS Platform
public static PlatformKind Platform { get; }
[SupportedOSPlatformGuard("windows")]
public static bool IsWindows => Platform.HasFlag(PlatformKind.Windows);
[SupportedOSPlatformGuard("linux")]
public static bool IsLinux => Platform.HasFlag(PlatformKind.Linux);
[SupportedOSPlatformGuard("macos")]
public static bool IsMacOS => Platform.HasFlag(PlatformKind.MacOS);
[UnsupportedOSPlatformGuard("windows")]
public static bool IsUnix => Platform.HasFlag(PlatformKind.Unix);
public static bool IsArm => Platform.HasFlag(PlatformKind.Arm);
public static bool IsX64 => Platform.HasFlag(PlatformKind.X64);
// Paths
/// <summary>
/// AppData directory path. On Windows this is %AppData%, on Linux and MacOS this is ~/.config
/// </summary>
public static DirectoryPath AppData { get; }
/// <summary>
/// AppData + AppName (e.g. %AppData%\StabilityMatrix)
/// </summary>
public static DirectoryPath AppDataHome { get; }
public static DirectoryPath AppDataHome { get; private set; }
/// <summary>
/// Set AppDataHome to a custom path. Used for testing.
/// </summary>
internal static void SetAppDataHome(string path)
{
AppDataHome = path;
}
/// <summary>
/// Current directory the app is in.
@ -57,7 +65,7 @@ public static class Compat
/// Current path to the app.
/// </summary>
public static FilePath AppCurrentPath => AppCurrentDir.JoinFile(GetExecutableName());
// File extensions
/// <summary>
/// Platform-specific executable extension.
@ -70,19 +78,21 @@ public static class Compat
/// ".dll" on Windows, ".dylib" on MacOS, ".so" on Linux.
/// </summary>
public static string DllExtension { get; }
/// <summary>
/// Delimiter for $PATH environment variable.
/// </summary>
public static char PathDelimiter => IsWindows ? ';' : ':';
static Compat()
{
var infoVersion = Assembly.GetCallingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
var infoVersion = Assembly
.GetCallingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
AppVersion = SemVersion.Parse(infoVersion ?? "0.0.0", SemVersionStyles.Strict);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Platform = PlatformKind.Windows;
@ -100,12 +110,13 @@ public static class Compat
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Platform = PlatformKind.Linux | PlatformKind.Unix;
// For AppImage builds, the path is in `$APPIMAGE`
var appPath = Environment.GetEnvironmentVariable("APPIMAGE") ??
AppContext.BaseDirectory;
AppCurrentDir = Path.GetDirectoryName(appPath) ??
throw new Exception("Could not find application directory");
var appPath =
Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory;
AppCurrentDir =
Path.GetDirectoryName(appPath)
?? throw new Exception("Could not find application directory");
ExeExtension = "";
DllExtension = ".so";
}
@ -113,7 +124,7 @@ public static class Compat
{
throw new PlatformNotSupportedException();
}
if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
Platform |= PlatformKind.Arm;
@ -122,9 +133,20 @@ public static class Compat
{
Platform |= PlatformKind.X64;
}
AppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
AppDataHome = AppData + AppName;
if (
Environment.GetEnvironmentVariable("STABILITY_MATRIX_APPDATAHOME") is
{ } appDataOverride
)
{
AppDataHome = appDataOverride;
}
else
{
AppDataHome = AppData + AppName;
}
}
/// <summary>
@ -142,12 +164,13 @@ public static class Compat
return target;
}
}
throw new PlatformNotSupportedException(
$"Platform {Platform.ToString()} is not in supported targets: " +
$"{string.Join(", ", targets.Select(t => t.platform.ToString()))}");
$"Platform {Platform.ToString()} is not in supported targets: "
+ $"{string.Join(", ", targets.Select(t => t.platform.ToString()))}"
);
}
/// <summary>
/// Get the current application executable name.
/// </summary>
@ -172,12 +195,15 @@ public static class Compat
}
return Path.GetFileName(fullPath);
}
public static string GetEnvPathWithExtensions(params string[] paths)
{
var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process);
var currentPath = Environment.GetEnvironmentVariable(
"PATH",
EnvironmentVariableTarget.Process
);
var newPath = string.Join(PathDelimiter, paths);
if (string.IsNullOrEmpty(currentPath))
{
return string.Join(PathDelimiter, paths);

4
StabilityMatrix.Core/Python/PyVenvRunner.cs

@ -643,12 +643,12 @@ public class PyVenvRunner : IDisposable, IAsyncDisposable
try
{
await Process
.WaitForExitAsync(new CancellationTokenSource(1000).Token)
.WaitForExitAsync(new CancellationTokenSource(5000).Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException e)
{
Logger.Error(e, "Venv Process did not exit in time in DisposeAsync");
Logger.Warn(e, "Venv Process did not exit in time in DisposeAsync");
Process.CancelStreamReaders();
}

3
StabilityMatrix.Core/StabilityMatrix.Core.csproj

@ -10,7 +10,8 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StabilityMatrix.Tests" />
<InternalsVisibleTo Include="StabilityMatrix.Tests" />
<InternalsVisibleTo Include="StabilityMatrix.UITests" />
</ItemGroup>
<ItemGroup>

12
StabilityMatrix.UITests/Attributes/TestPriorityAttribute.cs

@ -0,0 +1,12 @@
namespace StabilityMatrix.UITests.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public class TestPriorityAttribute : Attribute
{
public int Priority { get; private set; }
public TestPriorityAttribute(int priority)
{
Priority = priority;
}
}

17
StabilityMatrix.UITests/Extensions/VisualExtensions.cs

@ -0,0 +1,17 @@
using Avalonia.Controls;
namespace StabilityMatrix.UITests.Extensions;
public static class VisualExtensions
{
public static Rect GetRelativeBounds(this Visual visual, TopLevel topLevel)
{
var origin =
visual.TranslatePoint(new Point(0, 0), topLevel)
?? throw new NullReferenceException("Origin is null");
var bounds = new Rect(origin, visual.Bounds.Size);
return bounds;
}
}

73
StabilityMatrix.UITests/Extensions/WindowExtensions.cs

@ -0,0 +1,73 @@
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace StabilityMatrix.UITests.Extensions;
/// <summary>
/// Window extensions for UI tests
/// </summary>
public static class WindowExtensions
{
public static void ClickTarget(this TopLevel topLevel, Control target)
{
// Check target is part of the visual tree
var targetVisualRoot = target.GetVisualRoot();
if (targetVisualRoot is not TopLevel)
{
throw new ArgumentException("Target is not part of the visual tree");
}
if (targetVisualRoot.Equals(topLevel))
{
throw new ArgumentException(
"Target is not part of the same visual tree as the top level"
);
}
var point =
target.TranslatePoint(
new Point(target.Bounds.Width / 2, target.Bounds.Height / 2),
topLevel
) ?? throw new NullReferenceException("Point is null");
topLevel.MouseMove(point);
topLevel.MouseDown(point, MouseButton.Left);
topLevel.MouseUp(point, MouseButton.Left);
// Return mouse to outside of window
topLevel.MouseMove(new Point(-50, -50));
}
public static async Task ClickTargetAsync(this TopLevel topLevel, Control target)
{
// Check target is part of the visual tree
var targetVisualRoot = target.GetVisualRoot();
if (targetVisualRoot is not TopLevel)
{
throw new ArgumentException("Target is not part of the visual tree");
}
if (!targetVisualRoot.Equals(topLevel))
{
throw new ArgumentException(
"Target is not part of the same visual tree as the top level"
);
}
var point =
target.TranslatePoint(
new Point(target.Bounds.Width / 2, target.Bounds.Height / 2),
topLevel
) ?? throw new NullReferenceException("Point is null");
topLevel.MouseMove(point);
topLevel.MouseDown(point, MouseButton.Left);
topLevel.MouseUp(point, MouseButton.Left);
await Task.Delay(40);
// Return mouse to outside of window
topLevel.MouseMove(new Point(-50, -50));
Dispatcher.UIThread.Invoke(() => Dispatcher.UIThread.RunJobs());
}
}

149
StabilityMatrix.UITests/MainWindowTests.cs

@ -0,0 +1,149 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing;
using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.UITests.Extensions;
namespace StabilityMatrix.UITests;
[UsesVerify]
[Collection("TempDir")]
[TestCaseOrderer("StabilityMatrix.UITests.PriorityOrderer", "StabilityMatrix.UITests")]
public class MainWindowTests
{
private static IServiceProvider Services => App.Services;
private static (AppWindow, MainWindowViewModel)? currentMainWindow;
private static VerifySettings Settings
{
get
{
var settings = new VerifySettings();
settings.IgnoreMembers<MainWindowViewModel>(
vm => vm.Pages,
vm => vm.FooterPages,
vm => vm.CurrentPage
);
settings.DisableDiff();
return settings;
}
}
private static (AppWindow, MainWindowViewModel) GetMainWindow()
{
if (currentMainWindow is not null)
{
return currentMainWindow.Value;
}
var window = Services.GetRequiredService<MainWindow>();
var viewModel = Services.GetRequiredService<MainWindowViewModel>();
window.DataContext = viewModel;
window.SetDefaultFonts();
window.Width = 1400;
window.Height = 900;
App.VisualRoot = window;
App.StorageProvider = window.StorageProvider;
App.Clipboard = window.Clipboard ?? throw new NullReferenceException("Clipboard is null");
currentMainWindow = (window, viewModel);
return currentMainWindow.Value;
}
private static BetterContentDialog? GetWindowDialog(Visual window)
{
return window
.FindDescendantOfType<VisualLayerManager>()
?.FindDescendantOfType<OverlayLayer>()
?.FindDescendantOfType<DialogHost>()
?.FindDescendantOfType<LayoutTransformControl>()
?.FindDescendantOfType<VisualLayerManager>()
?.FindDescendantOfType<BetterContentDialog>();
}
private static IEnumerable<BetterContentDialog> EnumerateWindowDialogs(Visual window)
{
return window
.FindDescendantOfType<VisualLayerManager>()
?.FindDescendantOfType<OverlayLayer>()
?.FindDescendantOfType<DialogHost>()
?.FindDescendantOfType<LayoutTransformControl>()
?.FindDescendantOfType<VisualLayerManager>()
?.GetVisualDescendants()
.OfType<BetterContentDialog>() ?? Enumerable.Empty<BetterContentDialog>();
}
private async Task<(BetterContentDialog, T)> WaitForDialog<T>(Visual window)
where T : Control
{
var dialogs = await WaitHelper.WaitForConditionAsync(
() => EnumerateWindowDialogs(window).ToList(),
list => list.Any(dialog => dialog.Content is T)
);
if (dialogs.Count == 0)
{
throw new InvalidOperationException("No dialogs found");
}
var contentDialog = dialogs.First(dialog => dialog.Content is T);
return (contentDialog, contentDialog.Content as T)!;
}
[AvaloniaFact, TestPriority(1)]
public async Task MainWindow_ShouldOpen()
{
var (window, _) = GetMainWindow();
window.Show();
await Task.Delay(300);
Dispatcher.UIThread.RunJobs();
// Find the select data directory dialog
var selectDataDirectoryDialog = await WaitHelper.WaitForNotNullAsync(
() => GetWindowDialog(window)
);
Assert.NotNull(selectDataDirectoryDialog);
// Click continue button
var continueButton = selectDataDirectoryDialog
.GetVisualDescendants()
.OfType<Button>()
.First(b => b.Content as string == "Continue");
await window.ClickTargetAsync(continueButton);
// Find the one click install dialog
var oneClickDialog = await WaitHelper.WaitForConditionAsync(
() => GetWindowDialog(window),
d => d?.Content is OneClickInstallDialog
);
Assert.NotNull(oneClickDialog);
await Task.Delay(1800);
await Verify(window, Settings);
}
[AvaloniaFact, TestPriority(2)]
public async Task MainWindowViewModel_ShouldOk()
{
var viewModel = Services.GetRequiredService<MainWindowViewModel>();
await Verify(viewModel, Settings);
}
}

29
StabilityMatrix.UITests/ModuleInit.cs

@ -0,0 +1,29 @@
using System.Runtime.CompilerServices;
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace StabilityMatrix.UITests;
public static class ModuleInit
{
[ModuleInitializer]
public static void Init() => VerifyAvalonia.Initialize();
[ModuleInitializer]
public static void InitOther() => VerifierSettings.InitializePlugins();
[ModuleInitializer]
public static void ConfigureVerify()
{
VerifyPhash.RegisterComparer("png");
DerivePathInfo(
(sourceFile, projectDirectory, type, method) =>
new PathInfo(
directory: Path.Combine(projectDirectory, "Snapshots"),
typeName: type.Name,
methodName: method.Name
)
);
}
}

57
StabilityMatrix.UITests/PriorityOrderer.cs

@ -0,0 +1,57 @@
using StabilityMatrix.UITests.Attributes;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace StabilityMatrix.UITests;
public class PriorityOrderer : ITestCaseOrderer
{
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
where TTestCase : ITestCase
{
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
foreach (var testCase in testCases)
{
var priority = 0;
foreach (
var attr in testCase.TestMethod.Method.GetCustomAttributes(
typeof(TestPriorityAttribute).AssemblyQualifiedName
)
)
{
priority = attr.GetNamedArgument<int>("Priority");
}
GetOrCreate(sortedMethods, priority).Add(testCase);
}
foreach (var list in sortedMethods.Keys.Select(priority => sortedMethods[priority]))
{
list.Sort(
(x, y) =>
StringComparer.OrdinalIgnoreCase.Compare(
x.TestMethod.Method.Name,
y.TestMethod.Method.Name
)
);
foreach (var testCase in list)
{
yield return testCase;
}
}
}
private static TValue GetOrCreate<TKey, TValue>(IDictionary<TKey, TValue> dictionary, TKey key)
where TValue : new()
{
if (dictionary.TryGetValue(key, out var result))
return result;
result = new TValue();
dictionary[key] = result;
return result;
}
}

39
StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt

@ -0,0 +1,39 @@
{
Greeting: Welcome to Avalonia!,
ProgressManagerViewModel: {
Title: Download Manager,
IconSource: {
Type: SymbolIconSource
},
IsOpen: false,
CanNavigateNext: false,
CanNavigatePrevious: false,
HasErrors: false
},
UpdateViewModel: {
IsUpdateAvailable: true,
UpdateInfo: {
Version: {
Major: 2,
Minor: 999,
Prerelease: ,
IsPrerelease: false,
IsRelease: true,
Metadata:
},
ReleaseDate: DateTimeOffset_1,
Channel: Stable,
Type: Normal,
DownloadUrl: https://example.org,
ChangelogUrl: https://example.org,
HashBlake3: 46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0,
Signature: IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA==
},
ShowProgressBar: false,
CurrentVersionText: v1.0.0,
NewVersionText: v2.999.0,
InstallUpdateCommand: UpdateViewModel.InstallUpdate(),
HasErrors: false
},
HasErrors: false
}

BIN
StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindow_ShouldOpen.verified.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

451
StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindow_ShouldOpen.verified.txt

@ -0,0 +1,451 @@
{
Type: MainWindow,
Title: Stability Matrix,
Icon: {},
TransparencyLevelHint: [
{},
{},
{}
],
TransparencyBackgroundFallback: Transparent,
Content: {
Type: Grid,
Children: [
{
Type: Grid,
Background: Transparent,
Height: 32.0,
Name: TitleBarHost,
Children: [
{
Type: Image,
Source: {
Dpi: {
X: 96.0,
Y: 96.0,
Length: 135.7645019878171,
SquaredLength: 18432.0
},
Size: {
AspectRatio: 1.0,
Width: 256.0,
Height: 256.0
},
PixelSize: {
AspectRatio: 1.0,
Width: 256,
Height: 256
},
Format: {
BitsPerPixel: 32
}
},
IsHitTestVisible: false,
Width: 18.0,
Height: 18.0,
Margin: 12,4,12,4,
IsVisible: true,
Name: WindowIcon
},
{
Type: TextBlock,
FontSize: 12.0,
Text: Stability Matrix,
IsHitTestVisible: false,
VerticalAlignment: Center,
IsVisible: true
},
{
Type: Border,
Padding: 6
}
]
},
{
Type: NavigationView,
Content: {
Type: Frame,
Content: {
Type: LaunchPageView,
Content: {
Type: Grid,
Children: [
{
Type: Grid,
Margin: 0,8,0,8,
Children: [
{
Type: Grid,
Margin: 16,8,0,0,
Children: [
{
Type: Grid,
Column: 0,
Row: 0,
Name: LaunchButtonGrid,
Children: [
{
Type: Button,
Command: LaunchPageViewModel.LaunchAsync(string command),
Content: Launch,
Width: 95.0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch,
IsVisible: false
},
{
Type: SplitButton,
Command: LaunchPageViewModel.LaunchAsync(string command),
Flyout: {
Type: FAMenuFlyout
},
Content: Launch,
Width: 104.0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch,
IsVisible: false
}
]
},
{
Type: TeachingTip,
Name: TeachingTip1
},
{
Type: Grid,
Column: 0,
Row: 0,
IsVisible: false,
Name: StopButtonGrid,
Children: [
{
Type: Button,
Command: {},
Content: Stop,
Width: 95.0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch,
IsVisible: false
},
{
Type: Button,
Command: {},
Content: Stop,
Width: 104.0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch,
IsVisible: false
}
]
},
{
Type: Button,
Command: LaunchPageViewModel.Config(),
Content: {
Type: SymbolIcon
},
FontSize: 16.0,
Width: 48.0,
Margin: 8,0,0,0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch
}
]
},
{
Type: ComboBox,
SelectedIndex: -1,
Selection: {
SingleSelect: true,
SelectedIndex: -1,
AnchorIndex: -1
},
SelectionMode: Single,
ItemTemplate: {
DataType: InstalledPackage,
Content: {
Type: Func<IServiceProvider, object>,
Target: XamlIlRuntimeHelpers.<>c__DisplayClass1_0<Control>,
Method: System.Object DeferredTransformationFactoryV2(System.IServiceProvider)
}
},
IsEnabled: true,
Margin: 8,8,0,0,
HorizontalAlignment: Stretch,
VerticalAlignment: Top,
Name: SelectPackageComboBox
},
{
Type: ToggleButton,
IsChecked: true,
Content: {
Type: Icon,
Template: {
Content: {
Type: Func<IServiceProvider, object>,
Target: XamlIlRuntimeHelpers.<>c__DisplayClass1_0<Control>,
Method: System.Object DeferredTransformationFactoryV2(System.IServiceProvider)
}
},
RenderTransform: {
Type: TransformGroup,
Children: [
{
Type: RotateTransform
}
]
}
},
FontSize: 16.0,
Width: 48.0,
Margin: 8,8,0,0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch
},
{
Type: ToggleButton,
IsChecked: false,
Content: {
Type: SymbolIcon
},
FontSize: 16.0,
Width: 48.0,
Margin: 8,8,16,0,
HorizontalAlignment: Left,
VerticalAlignment: Stretch
}
]
},
{
Type: TextEditor,
FontFamily: Cascadia Code,
Margin: 8,8,16,10,
DataContext: {
IsUpdatesRunning: false,
WriteCursorLockTimeout: 00:00:00.1000000,
Document: {
_undoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
Text: ,
Version: {},
IsInUpdate: false,
Lines: [
{
IsDeleted: false,
LineNumber: 1
}
],
LineTrackers: [
{}
],
UndoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
LineCount: 1
}
},
Name: Console
},
{
Type: Grid,
Row: 1,
Children: [
{
Type: StackPanel,
Spacing: 4.0,
Margin: 8,
Children: [
{
Type: InfoBar,
Margin: 0
},
{
Type: InfoBar,
Margin: 0
}
]
}
]
},
{
Type: Button,
Command: {},
Content: Open Web UI,
FontSize: 12.0,
Margin: 24,0,24,8,
HorizontalAlignment: Stretch,
IsVisible: false
}
]
},
DataContext: {
Title: Launch,
IconSource: {
Type: SymbolIconSource
},
Console: {
IsUpdatesRunning: false,
WriteCursorLockTimeout: 00:00:00.1000000,
Document: {
_undoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
Text: ,
Version: {},
IsInUpdate: false,
Lines: [
{
IsDeleted: false,
LineNumber: 1
}
],
LineTrackers: [
{}
],
UndoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
LineCount: 1
}
},
LaunchButtonVisibility: false,
StopButtonVisibility: false,
IsLaunchTeachingTipsOpen: false,
ShowWebUiButton: false,
AutoScrollToEnd: true,
ShowManualInputPrompt: false,
ShowConfirmInputPrompt: false,
LaunchCommand: LaunchPageViewModel.LaunchAsync(string command),
ConfigCommand: LaunchPageViewModel.Config(),
SendConfirmInputCommand: LaunchPageViewModel.SendConfirmInput(bool value),
SendManualInputCommand: LaunchPageViewModel.SendManualInput(string input),
CanNavigateNext: false,
CanNavigatePrevious: false,
HasErrors: false
}
},
Name: FrameView
},
Name: NavigationView
},
{
Type: TeachingTip,
Name: UpdateAvailableTeachingTip
}
]
},
Background: #ff101010,
FontFamily: Segoe UI Variable Text,
Width: 1400.0,
Height: 900.0,
IsVisible: true,
DataContext: {
Greeting: Welcome to Avalonia!,
ProgressManagerViewModel: {
Title: Download Manager,
IconSource: {
Type: SymbolIconSource
},
IsOpen: false,
CanNavigateNext: false,
CanNavigatePrevious: false,
HasErrors: false
},
UpdateViewModel: {
IsUpdateAvailable: true,
UpdateInfo: {
Version: {
Major: 2,
Minor: 999,
Prerelease: ,
IsPrerelease: false,
IsRelease: true,
Metadata:
},
ReleaseDate: DateTimeOffset_1,
Channel: Stable,
Type: Normal,
DownloadUrl: https://example.org,
ChangelogUrl: https://example.org,
HashBlake3: 46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0,
Signature: IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA==
},
ShowProgressBar: false,
CurrentVersionText: v1.0.0,
NewVersionText: v2.999.0,
InstallUpdateCommand: UpdateViewModel.InstallUpdate(),
HasErrors: false
},
SelectedCategory: {
Title: Launch,
IconSource: {
Type: SymbolIconSource
},
Console: {
IsUpdatesRunning: false,
WriteCursorLockTimeout: 00:00:00.1000000,
Document: {
_undoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
Text: ,
Version: {},
IsInUpdate: false,
Lines: [
{
IsDeleted: false,
LineNumber: 1
}
],
LineTrackers: [
{}
],
UndoStack: {
IsOriginalFile: true,
AcceptChanges: true,
CanUndo: false,
CanRedo: false,
SizeLimit: 2147483647
},
LineCount: 1
}
},
LaunchButtonVisibility: false,
StopButtonVisibility: false,
IsLaunchTeachingTipsOpen: false,
ShowWebUiButton: false,
AutoScrollToEnd: true,
ShowManualInputPrompt: false,
ShowConfirmInputPrompt: false,
LaunchCommand: LaunchPageViewModel.LaunchAsync(string command),
ConfigCommand: LaunchPageViewModel.Config(),
SendConfirmInputCommand: LaunchPageViewModel.SendConfirmInput(bool value),
SendManualInputCommand: LaunchPageViewModel.SendManualInput(string input),
CanNavigateNext: false,
CanNavigatePrevious: false,
HasErrors: false
},
HasErrors: false
}
}

37
StabilityMatrix.UITests/StabilityMatrix.UITests.csproj

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Headless.XUnit" Version="11.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Verify" Version="22.1.4" />
<PackageReference Include="Verify.CommunityToolkit.Mvvm" Version="0.1.0" />
<PackageReference Include="Verify.Avalonia" Version="1.0.1" />
<PackageReference Include="Verify.Phash" Version="3.1.0" />
<PackageReference Include="Verify.Xunit" Version="22.1.4" />
<PackageReference Include="xunit" Version="2.6.1"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.2.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StabilityMatrix.Avalonia\StabilityMatrix.Avalonia.csproj" />
<ProjectReference Include="..\StabilityMatrix.Core\StabilityMatrix.Core.csproj" />
</ItemGroup>
</Project>

40
StabilityMatrix.UITests/TempDirFixture.cs

@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
namespace StabilityMatrix.UITests;
public class TempDirFixture : IDisposable
{
public static string ModuleTempDir { get; set; }
static TempDirFixture()
{
var tempDir = Path.Combine(Path.GetTempPath(), "StabilityMatrixTest");
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
Directory.CreateDirectory(tempDir);
ModuleTempDir = tempDir;
// ReSharper disable once LocalizableElement
Console.WriteLine($"Using temp dir: {ModuleTempDir}");
}
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(ModuleTempDir))
{
// ReSharper disable once LocalizableElement
Console.WriteLine($"Deleting temp dir: {ModuleTempDir}");
Directory.Delete(ModuleTempDir, true);
}
GC.SuppressFinalize(this);
}
}
[CollectionDefinition("TempDir")]
public class TempDirCollection : ICollectionFixture<TempDirFixture> { }

78
StabilityMatrix.UITests/TestAppBuilder.cs

@ -0,0 +1,78 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NSubstitute.Extensions;
using Semver;
using StabilityMatrix.Avalonia;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
using StabilityMatrix.UITests;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
namespace StabilityMatrix.UITests;
public static class TestAppBuilder
{
public static AppBuilder BuildAvaloniaApp()
{
ConfigureGlobals();
Program.SetupAvaloniaApp();
App.BeforeBuildServiceProvider += (_, x) => ConfigureAppServices(x);
return AppBuilder
.Configure<App>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
}
private static void ConfigureGlobals()
{
var tempDir = TempDirFixture.ModuleTempDir;
var globalSettings = Path.Combine(tempDir, "AppDataHome");
Compat.SetAppDataHome(globalSettings);
}
private static void ConfigureAppServices(IServiceCollection serviceCollection)
{
// ISettingsManager
var settingsManager = Substitute.ForPartsOf<SettingsManager>();
serviceCollection.AddSingleton<ISettingsManager>(settingsManager);
// IUpdateHelper
var mockUpdateInfo = new UpdateInfo(
SemVersion.Parse("2.999.0"),
DateTimeOffset.UnixEpoch,
UpdateChannel.Stable,
UpdateType.Normal,
"https://example.org",
"https://example.org",
"46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0",
"IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA=="
);
var updateHelper = Substitute.For<IUpdateHelper>();
updateHelper
.Configure()
.StartCheckingForUpdates()
.Returns(Task.CompletedTask)
.AndDoes(_ => EventManager.Instance.OnUpdateAvailable(mockUpdateInfo));
serviceCollection.AddSingleton(updateHelper);
// UpdateViewModel
var updateViewModel = Substitute.ForPartsOf<UpdateViewModel>(
settingsManager,
null,
updateHelper
);
updateViewModel.Configure().GetReleaseNotes("").Returns("Test");
serviceCollection.AddSingleton(updateViewModel);
}
}

6
StabilityMatrix.UITests/Usings.cs

@ -0,0 +1,6 @@
global using Xunit;
global using Avalonia;
global using Avalonia.Headless;
global using Avalonia.Headless.XUnit;
global using Avalonia.Input;
global using StabilityMatrix.UITests.Attributes;

15
StabilityMatrix.UITests/VerifyConfig.cs

@ -0,0 +1,15 @@
using AsyncAwaitBestPractices;
namespace StabilityMatrix.UITests;
internal static class VerifyConfig
{
public static VerifySettings Default { get; }
static VerifyConfig()
{
Default = new VerifySettings();
Default.IgnoreMembersWithType<WeakEventManager>();
Default.DisableDiff();
}
}

51
StabilityMatrix.UITests/WaitHelper.cs

@ -0,0 +1,51 @@
namespace StabilityMatrix.UITests;
public static class WaitHelper
{
public static async Task<T> WaitForConditionAsync<T>(
Func<T> getter,
Func<T, bool> condition,
int delayMs = 50,
int maxAttempts = 20,
int initialDelayMs = 100
)
{
await Task.Delay(initialDelayMs);
for (var i = 0; i < maxAttempts; i++)
{
await Task.Delay(delayMs);
var result = getter();
if (condition(result))
{
return result;
}
}
throw new TimeoutException("Waited too long for a condition to be met");
}
public static async Task<T> WaitForNotNullAsync<T>(
Func<T?> getter,
int delayMs = 50,
int maxAttempts = 20,
int initialDelayMs = 100
)
{
await Task.Delay(initialDelayMs);
for (var i = 0; i < maxAttempts; i++)
{
await Task.Delay(delayMs);
if (getter() is { } result)
{
return result;
}
}
throw new TimeoutException("Waited too long for a non-null value");
}
}

6
StabilityMatrix.sln

@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Avalonia",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Avalonia.Diagnostics", "StabilityMatrix.Avalonia.Diagnostics\StabilityMatrix.Avalonia.Diagnostics.csproj", "{6D088B89-12D4-4EA0-BA6B-305C7D10C084}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.UITests", "StabilityMatrix.UITests\StabilityMatrix.UITests.csproj", "{8C7EDDD1-7FC1-4A15-B379-910A8DA7BCA6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -37,6 +39,10 @@ Global
{6D088B89-12D4-4EA0-BA6B-305C7D10C084}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D088B89-12D4-4EA0-BA6B-305C7D10C084}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D088B89-12D4-4EA0-BA6B-305C7D10C084}.Release|Any CPU.Build.0 = Release|Any CPU
{8C7EDDD1-7FC1-4A15-B379-910A8DA7BCA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C7EDDD1-7FC1-4A15-B379-910A8DA7BCA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C7EDDD1-7FC1-4A15-B379-910A8DA7BCA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C7EDDD1-7FC1-4A15-B379-910A8DA7BCA6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

Loading…
Cancel
Save