Files
DodoSSH/tests/DodoSSH.Client.App.Tests/UpdateFlowTests.cs
T
jaap-jan ca7fee2358 Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That
is two independent faults in one method, either of which breaks it on its own,
and they hid each other: the first kills the process before the second can be
observed, and the second is silent by construction.

The pending intent handed to commit was implicit — an action string with no
component behind it. A mutable pending intent may not wrap one of those from API
34, and this head targets 36, so every current phone threw IllegalArgumentException
before commit was reached. Nothing caught it, so it left the command handler,
passed the dispatcher and took the process with it. That is the closing.

Below 34, where it did not throw, it still installed nothing. An application
holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets
no verdict back from a commit: what the platform answers first is
STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in
EXTRA_INTENT for the application to start. Android does not draw it on its own.
The comment here asserted the opposite — that a pending intent is required whether
or not anything listens, and that nothing needed to — so no receiver was ever
written, and the session was written, committed and left staged forever.

So there is a receiver now, not exported because the only sender is this
application's own commit, and the intent naming it is explicit, which is the same
change that stops the throw. Sessions are abandoned when anything fails, since one
created and neither committed nor abandoned stays staged against a per-application
cap — a repeating fault would have started failing at CreateSession instead, which
is the same bug wearing a completely unrelated face.

The reporting is the part worth keeping even after the cause is gone. Where
applying ends the process an exception has nowhere to go; where it does not, which
is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now
answers the way CheckNowAsync already did, and the regression test asserts the
absence of a throw rather than the presence of one.

ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step
longer than it reads. Check 17.5 needed no rewording — it asks for the installer
appearing by name, which is exactly the thing that never happened — so what it
gets instead is the two symptoms named, because both present as a dead button. It
is the only thing in the project that can catch either, and it plainly was never
run against a real pair of builds.

Note for whoever takes the next nightly: a broken updater cannot install its own
fix. The phone is running the code this commit replaces, so the first build
carrying it has to be sideloaded by hand; the ones after that install normally.

Compile-verified and manifest-verified — the receiver reaches the generated
manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for.
2026-08-05 22:31:33 +02:00

417 lines
14 KiB
C#

using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Finding a newer build, fetching it, and not installing it until somebody says so.
/// </summary>
/// <remarks>
/// <para>
/// The view model is driven a pass at a time through <c>CheckOnceAsync</c> rather than through its timer,
/// which is the same split <c>VaultViewModel</c> makes between its sync pass and its sync loop and for the
/// same reason: a suite that waited on a <c>PeriodicTimer</c> would be testing the clock.
/// </para>
/// <para>
/// The load-bearing test here is <see cref="AReadyUpdate_IsNeverAppliedOnItsOwn"/>. Everything else is
/// about how the feature behaves; that one is about the promise it makes.
/// </para>
/// </remarks>
public sealed class UpdateFlowTests : IDisposable
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private readonly FakeUpdateChannel channel = new();
private int liveSessions;
private int restarts;
private ClientPaths Paths => new(directory);
/// <remarks>
/// The real settings store over a real temporary directory, not a stand-in. Persistence is one of the
/// things being asserted, and the file is the thing that persists.
/// </remarks>
private UpdateViewModel Build() =>
new(
channel,
new ClientSettingsStore(Paths),
TimeProvider.System,
() => liveSessions,
restart: update =>
{
restarts++;
channel.ApplyAndRestart(update);
return Task.CompletedTask;
});
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ABuildThatCannotReplaceItself_OffersNothing()
{
channel.IsSupported = false;
var updates = Build();
updates.State.ShouldBe(UpdateState.Unsupported);
updates.IsUnsupported.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
updates.IsBannerShowing.ShouldBeFalse();
}
[Fact]
public void ABuildThatCannotReplaceItself_StillKnowsItsOwnVersion()
{
channel.IsSupported = false;
channel.CurrentVersion = "0.4.2";
Build().CurrentVersion.ShouldBe("0.4.2");
}
[Fact]
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
{
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFindsNothing_SaysSo()
{
channel.CurrentVersion = "1.2.0";
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldContain("1.2.0");
}
[Fact]
public async Task AnUpdateThatIsFound_IsFetchedWithoutBeingAskedAbout()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Downloads.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Ready);
updates.ReadyVersion.ShouldBe("1.3.0");
updates.DownloadPercent.ShouldBe(100);
updates.IsBannerShowing.ShouldBeTrue();
}
/// <remarks>
/// The whole policy in one assertion. A fetched update sits until a person presses the button, or until
/// the application is next started for their own reasons — because a restart ends every shell, and this
/// application has spent a lot of design effort on shells surviving a lock.
/// </remarks>
[Fact]
public async Task AReadyUpdate_IsNeverAppliedOnItsOwn()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Ready);
channel.Restarts.ShouldBe(0);
restarts.ShouldBe(0);
}
[Fact]
public async Task RestartingApplies_TheUpdateThatWasFound()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
}
/// <remarks>
/// ◆ The regression for an install that closed the application instead of installing anything. On the
/// phone applying is a request that returns, so a failure in it throws on the spot rather than being
/// cut short by the process ending — and with nothing catching it, it left a command handler, passed
/// the dispatcher and took the process with it. Pressing INSTALL closed the application, said nothing,
/// and installed nothing, which reads as anything but a bug in the updater.
/// </remarks>
[Fact]
public async Task AnInstallThatFails_IsReportedRatherThanThrown()
{
channel.Available = new AvailableUpdate("1.3.0");
channel.ApplyingEndsTheProcess = false;
channel.RestartFailure = new InvalidOperationException("The installer refused the session.");
var updates = Build();
await updates.CheckOnceAsync(Token);
// Not Should.ThrowAsync: the assertion is that this does not throw at all.
await updates.RestartNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Failed);
updates.Status.ShouldBe("The installer refused the session.");
}
[Fact]
public async Task RestartingWithNothingReady_DoesNothing()
{
var updates = Build();
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(0);
}
[Fact]
public async Task DismissingTheBanner_KeepsTheOfferOnPreferences()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.DismissBannerCommand.Execute(null);
updates.IsBannerShowing.ShouldBeFalse();
updates.IsReady.ShouldBeTrue();
}
/// <remarks>
/// A forge that cannot be reached is a laptop on a train. Nothing asked, so nothing is said, and the
/// state goes back to Idle rather than to Failed — Failed is reserved for something a person is waiting
/// on an answer to.
/// </remarks>
[Fact]
public async Task ABackgroundCheckThatFails_IsNotAnnounced()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFails_SaysWhy()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Failed);
updates.Status.ShouldBe("no such host");
}
[Fact]
public async Task TurningOffAutomaticChecks_StopsThePassDoingAnything()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
updates.IsAutomatic = false;
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(0);
}
/// <remarks>
/// A second view model over the same <see cref="ClientPaths"/>, which is how this repository tests that
/// a preference reached the disk — the same shape as the terminal font size's own persistence test.
/// </remarks>
[Fact]
public void TurningOffAutomaticChecks_IsStillOffOnTheNextLaunch()
{
var first = Build();
first.IsAutomatic.ShouldBeTrue();
first.IsAutomatic = false;
Build().IsAutomatic.ShouldBeFalse();
}
/// <summary>Storing one preference does not discard the others.</summary>
/// <remarks>
/// <para>
/// Pins the read-modify-write in <c>OnIsAutomaticChanged</c>, which nothing else covered: writing this
/// setting reads the file first, so a font size chosen earlier is still there afterwards. Writing the
/// view model's own state instead would silently reset every preference it does not hold.
/// </para>
/// <para>
/// Note the limit of what read-modify-write buys here, because the comment it guards is easy to read
/// as promising more. It preserves settings <em>this build knows about</em>. A key written by a newer
/// build is dropped, because the store deserialises with
/// <c>JsonUnmappedMemberHandling.Skip</c> — unmapped members are skipped rather than carried, so they
/// do not survive a round trip. That is asserted below rather than left as an assumption, so that
/// anybody who needs forward-compatibility discovers the cost here instead of in the field.
/// </para>
/// </remarks>
[Fact]
public void StoringOnePreference_KeepsTheOthersThisBuildKnows()
{
Directory.CreateDirectory(directory);
File.WriteAllText(
Paths.SettingsFile,
"""{"terminalFontSize":19,"somethingOnlyANewerBuildKnows":"keep me"}""");
Build().IsAutomatic = false;
var reread = new ClientSettingsStore(Paths).Read();
reread.TerminalFontSize.ShouldBe(19);
reread.AutomaticUpdateChecks.ShouldBeFalse();
// The honest limit, stated as an assertion: an unmapped key does not survive.
File.ReadAllText(Paths.SettingsFile).ShouldNotContain("somethingOnlyANewerBuildKnows");
}
/// <summary>
/// A setting missing from the file reads back as the default the record declares.
/// </summary>
/// <remarks>
/// <para>
/// This is the regression test for a defect that was already in the tree and could not bite until a
/// second preference existed. The JSON source generator builds a record through a synthesised
/// parameterised constructor and assigns <em>every</em> property from its argument array, so a member
/// absent from the file arrived as the CLR default and overwrote whatever a property initializer had
/// set. A settings file of <c>{}</c> read back a font size of 0 — clamped to the 8px floor rather than
/// the 13px the renderer draws at — and, once it existed, update checks off.
/// </para>
/// <para>
/// It is asserted on both settings, not just the new one, because the mechanism has nothing to do with
/// either: it is a property of how this record is deserialised, and the next preference somebody adds
/// inherits it. The fix is that the defaults live on the constructor parameters, which is the only
/// place the generator reads them from.
/// </para>
/// <para>
/// A file that exists and lacks the key is the case that matters, and it is not the same as no file at
/// all — <c>Read</c> short-circuits to a fresh record when the file is missing, which is why the bug
/// hid. Every machine that has ever run this application has a settings.json without the newer key.
/// </para>
/// </remarks>
[Fact]
public void ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault()
{
Directory.CreateDirectory(directory);
var store = new ClientSettingsStore(Paths);
File.WriteAllText(Paths.SettingsFile, "{}");
var fromEmptyObject = store.Read();
fromEmptyObject.AutomaticUpdateChecks.ShouldBeTrue();
fromEmptyObject.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
// The realistic shape: a file written by the build before this feature existed.
File.WriteAllText(Paths.SettingsFile, """{"terminalFontSize":19}""");
var fromOlderBuild = store.Read();
fromOlderBuild.TerminalFontSize.ShouldBe(19);
fromOlderBuild.AutomaticUpdateChecks.ShouldBeTrue();
}
[Fact]
public async Task TheRestartWarning_CountsTheShellsItWouldClose()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
liveSessions = 0;
updates.RestartWarning.ShouldContain("Nothing is connected");
liveSessions = 1;
updates.RestartWarning.ShouldContain("the shell you have open");
liveSessions = 3;
updates.RestartWarning.ShouldContain("the 3 shells you have open");
}
[Fact]
public async Task ADownloadInFlight_IsAStateTheScreenCanShow()
{
channel.Available = new AvailableUpdate("1.3.0");
channel.HoldDownload = new TaskCompletionSource();
var updates = Build();
var pass = updates.CheckOnceAsync(Token);
// The gate is what makes this observable at all; without it the download would be over before
// anything could look.
while (updates.State is not UpdateState.Downloading)
{
await Task.Yield();
}
updates.IsDownloading.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
channel.HoldDownload.SetResult();
await pass;
updates.State.ShouldBe(UpdateState.Ready);
}
[Fact]
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
}
[Fact]
public async Task DisposingStopsTheLoop()
{
var updates = Build();
updates.Start();
await updates.DisposeAsync();
await updates.DisposeAsync();
}
}