Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m44s
On IoT LTSC winget is absent, so Process.Start('winget') throws Win32Exception
('cannot find the file specified') rather than returning non-zero. That throw
propagated out of InstallAsync and failed the entire Apply ('Configuration failed').
AppInstaller is now fully exception-safe: a TryRunAsync wrapper converts launch
throws into a failed run, winget is resolved defensively (PATH -> bootstrap+re-probe
-> WindowsApps alias path) and when unavailable the installer skips apps and marks
them not-installed instead of throwing. Per-app launch throws are isolated too.
Two new tests cover probe-throws-skips and per-app-throw-isolated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
5.5 KiB
C#
107 lines
5.5 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using SilverOS.Welcome.Core.Apps;
|
|
using SilverOS.Welcome.Core.Apply;
|
|
|
|
public class AppInstallerTests
|
|
{
|
|
static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
|
|
new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };
|
|
|
|
static Mock<IProcessRunner> Runner(int exit = 0)
|
|
{
|
|
var m = new Mock<IProcessRunner>();
|
|
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(exit, "", ""));
|
|
return m;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Installs_each_selected_app_via_winget()
|
|
{
|
|
var run = Runner();
|
|
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
|
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
|
|
new Progress<ApplyProgress>(_ => { }));
|
|
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
|
|
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
|
|
It.IsAny<CancellationToken>()), Times.Once);
|
|
Assert.True(res.Single().Installed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bootstraps_winget_when_absent()
|
|
{
|
|
var run = Runner();
|
|
// Any winget probe (bare name OR the WindowsApps alias path) reports absent.
|
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(1, "", "not found"));
|
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
|
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
|
|
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
|
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_app_failure_does_not_stop_the_rest()
|
|
{
|
|
var run = new Mock<IProcessRunner>();
|
|
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
|
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(1, "", "fail"));
|
|
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
|
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
|
|
new Progress<ApplyProgress>(_ => { }));
|
|
Assert.False(res.First(r => r.Id == "bad").Installed);
|
|
Assert.True(res.First(r => r.Id == "good").Installed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Winget_launch_exception_does_not_crash_apply_and_skips_installs()
|
|
{
|
|
// On IoT LTSC winget is absent, so Process.Start throws Win32Exception instead of
|
|
// returning a non-zero code. The installer must swallow it, skip installs, and NOT throw.
|
|
var run = new Mock<IProcessRunner>();
|
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new System.ComponentModel.Win32Exception("The system cannot find the file specified."));
|
|
run.Setup(r => r.RunAsync("powershell.exe", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
|
|
|
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium"), App("git", "Git.Git") },
|
|
new Progress<ApplyProgress>(_ => { }));
|
|
|
|
Assert.Equal(2, res.Count);
|
|
Assert.All(res, r => Assert.False(r.Installed));
|
|
// never attempted an install once winget was unresolved
|
|
run.Verify(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("install --id")), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task App_install_exception_is_isolated_from_the_rest()
|
|
{
|
|
var run = new Mock<IProcessRunner>();
|
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new System.ComponentModel.Win32Exception("boom"));
|
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
|
|
|
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
|
|
new Progress<ApplyProgress>(_ => { }));
|
|
|
|
Assert.False(res.First(r => r.Id == "bad").Installed);
|
|
Assert.True(res.First(r => r.Id == "good").Installed);
|
|
}
|
|
}
|