## 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>
194 lines
5.2 KiB
C#
194 lines
5.2 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
using KofTwentyTwo.AppKit.Interaction;
|
|
using KofTwentyTwo.AppKit.Logging;
|
|
using KofTwentyTwo.AppKit.Updates;
|
|
|
|
|
|
namespace KofTwentyTwo.AppKit.Tests;
|
|
|
|
/// <summary>A per-test temporary directory, deleted on dispose.</summary>
|
|
internal sealed class TempDirectory : IDisposable
|
|
{
|
|
/// <summary>Creates a unique folder under the system temp directory.</summary>
|
|
public TempDirectory()
|
|
{
|
|
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "appkit-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(Path);
|
|
}
|
|
|
|
|
|
|
|
public string Path { get; }
|
|
|
|
|
|
|
|
/// <summary>A path inside the folder.</summary>
|
|
public string File(string name) => System.IO.Path.Combine(Path, name);
|
|
|
|
|
|
|
|
/// <summary>Deletes the folder; a handle a failing test left open is left for the OS temp cleaner.</summary>
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(Path, recursive: true);
|
|
}
|
|
catch(IOException)
|
|
{
|
|
// A handle a failing test left open; the OS temp cleaner gets it later.
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A settable clock in a fixed UTC+0 zone, so local time equals UTC.</summary>
|
|
internal sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
|
|
{
|
|
public DateTimeOffset Now { get; set; } = now;
|
|
|
|
|
|
|
|
/// <summary>The settable current time.</summary>
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
|
|
|
|
|
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>An <see cref="IActivityLog"/> that records entries in memory.</summary>
|
|
internal sealed class RecordingLog : IActivityLog
|
|
{
|
|
public List<string> Entries { get; } = [];
|
|
|
|
public string LogDirectory => "";
|
|
|
|
public string CurrentLogFilePath => "";
|
|
|
|
|
|
|
|
/// <summary>Records an INFO entry.</summary>
|
|
public void Info(string message) => Entries.Add("INFO " + message);
|
|
|
|
|
|
|
|
/// <summary>Records a WARN entry.</summary>
|
|
public void Warning(string message) => Entries.Add("WARN " + message);
|
|
|
|
|
|
|
|
/// <summary>Records an ERROR entry (the exception is not needed by the tests).</summary>
|
|
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>An <see cref="IUserPrompter"/> that records prompts and answers confirmations from a script.</summary>
|
|
internal sealed class RecordingPrompter(bool confirm = false) : IUserPrompter
|
|
{
|
|
public List<(string Title, string Message)> Messages { get; } = [];
|
|
|
|
public List<(string Title, string Message, string ConfirmText, string CancelText)> Confirmations { get; } = [];
|
|
|
|
|
|
|
|
/// <summary>Records the message.</summary>
|
|
public Task ShowMessageAsync(string title, string message)
|
|
{
|
|
Messages.Add((title, message));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Records the question and answers with the scripted choice.</summary>
|
|
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
|
|
{
|
|
Confirmations.Add((title, message, confirmText, cancelText));
|
|
return Task.FromResult(confirm);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A scriptable <see cref="IUpdateService"/>.</summary>
|
|
internal sealed class FakeUpdateService : IUpdateService
|
|
{
|
|
public bool IsSupported { get; set; } = true;
|
|
|
|
public string? CurrentVersion { get; set; } = "1.0.0";
|
|
|
|
public UpdateCheckResult NextCheck { get; set; } = UpdateCheckResult.UpToDate;
|
|
|
|
public string? ApplyError { get; set; }
|
|
|
|
public int ApplyCalls { get; private set; }
|
|
|
|
|
|
|
|
/// <summary>Returns the scripted check result.</summary>
|
|
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck);
|
|
|
|
|
|
|
|
/// <summary>Counts the call and returns the scripted error, or null for success.</summary>
|
|
public Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
|
|
{
|
|
ApplyCalls++;
|
|
return Task.FromResult(ApplyError);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A scriptable Velopack stand-in.</summary>
|
|
internal sealed class FakeBackend : IUpdateBackend
|
|
{
|
|
public bool IsInstalled { get; set; } = true;
|
|
|
|
public string? CurrentVersion { get; set; } = "1.0.0";
|
|
|
|
public string? Available { get; set; }
|
|
|
|
public Exception? CheckFailure { get; set; }
|
|
|
|
public Exception? DownloadFailure { get; set; }
|
|
|
|
public int Downloads { get; private set; }
|
|
|
|
public int Applies { get; private set; }
|
|
|
|
|
|
|
|
/// <summary>Returns the scripted available version, or fails with the scripted exception.</summary>
|
|
public Task<string?> CheckAsync()
|
|
=> CheckFailure is null ? Task.FromResult(Available) : Task.FromException<string?>(CheckFailure);
|
|
|
|
|
|
|
|
/// <summary>Counts the download and reports 100% progress, or fails with the scripted exception.</summary>
|
|
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken)
|
|
{
|
|
if(DownloadFailure is not null)
|
|
{
|
|
return Task.FromException(DownloadFailure);
|
|
}
|
|
Downloads++;
|
|
progress?.Invoke(100);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Counts the apply; the real backend would exit the process here.</summary>
|
|
public void ApplyAndRestart() => Applies++;
|
|
}
|