Files
sysadmin 3daa770584
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m44s
fix(apps): winget launch failure no longer crashes Apply
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>
2026-06-10 01:23:49 +01:00

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);
}
}