Public Access
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel then asked rpm for the installed version, got nothing because there is no rpm, read the silence as "0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the machine could never install, every six hours. UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that, UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only where the path already matched. CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The comment that called the fallback a deliberate choice is replaced with the reason it was wrong. LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a copy rpm owns gets the channel. The newer, equal and older comparisons were already covered. UpdateFlowTests covers the reason reaching the view model. Manual check 19.13. App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
663 lines
23 KiB
C#
663 lines
23 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 void ABuildThatCannotReplaceItself_ForAReasonOfItsOwn_SaysThatInstead()
|
|
{
|
|
var updates = new UpdateViewModel(
|
|
new UnavailableUpdateChannel("Update it with the package manager that installed it."),
|
|
new ClientSettingsStore(Paths),
|
|
TimeProvider.System,
|
|
() => 0,
|
|
restart: _ => Task.CompletedTask);
|
|
|
|
updates.IsUnsupported.ShouldBeTrue();
|
|
updates.UnsupportedReason.ShouldBe("Update it with the package manager that installed it.");
|
|
updates.IsUnsupportedForTheUsualReason.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ABuildThatCannotReplaceItself_ForTheUsualReasons_LeavesTheScreenItsOwnSentence()
|
|
{
|
|
channel.IsSupported = false;
|
|
|
|
var updates = Build();
|
|
|
|
updates.UnsupportedReason.ShouldBeNull();
|
|
updates.IsUnsupportedForTheUsualReason.ShouldBeTrue();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Asks, and does not fetch again. The distinction is the whole of the fix for a client that installed
|
|
/// two updates to move one step: the old guard skipped the <em>pass</em> once anything was ready, so the
|
|
/// banner was pinned to whatever build the process happened to see first. Not re-fetching is still the
|
|
/// rule; it is enforced by comparing versions now rather than by refusing to look.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APassWhileOneIsAlreadyReady_AsksAgainAndDoesNotFetchTwice()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
channel.Checks.ShouldBe(2);
|
|
channel.Downloads.ShouldBe(1, "the second pass found the build it had already fetched");
|
|
updates.State.ShouldBe(UpdateState.Ready);
|
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ The regression this whole change is about, in the case the timer can reach. A rolling nightly
|
|
/// channel keeps exactly one build, so a client left running does not accumulate a queue of updates — it
|
|
/// is offered whichever one the feed holds, and the pending build has to be replaced when that moves on.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APassWhileOneIsAlreadyReady_ReplacesItWhenTheFeedHasMovedOn()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
|
|
|
channel.Available = new AvailableUpdate("1.4.0");
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
channel.Downloads.ShouldBe(2);
|
|
updates.State.ShouldBe(UpdateState.Ready);
|
|
updates.ReadyVersion.ShouldBe("1.4.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A build superseded after LATER was pressed brings the banner back, because LATER was an answer about
|
|
/// a version and this is a different one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASupersededOffer_IsShownAgainEvenIfTheOldOneWasDismissed()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
updates.DismissBannerCommand.Execute(null);
|
|
updates.IsBannerShowing.ShouldBeFalse();
|
|
|
|
channel.Available = new AvailableUpdate("1.4.0");
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
updates.IsBannerShowing.ShouldBeTrue();
|
|
updates.ReadyHeadline.ShouldContain("1.4.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ The half the timer cannot reach, and the one a person actually hit: a build published between the
|
|
/// last pass and the press. Without this the press installs a build that was already superseded, and the
|
|
/// next launch offers the newer one straight away — two downloads, two installs and two restarts to move
|
|
/// one step.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Restarting_TakesOneLastLookAndInstallsWhatItFinds()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
// Published while the banner was up, which on a rolling channel replaces the release rather than
|
|
// adding to it — so the feed now holds only this one.
|
|
channel.Available = new AvailableUpdate("1.4.0");
|
|
|
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
|
|
|
channel.Restarts.ShouldBe(1);
|
|
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
|
restarts.ShouldBe(1, "one restart, not one per build");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The ordinary case, and what it costs: one question and no download. A press that re-fetched what was
|
|
/// already on disk would have made the fix worse than the bug.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Restarting_AsksOnceMoreAndFetchesNothingWhenNothingIsNewer()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
|
|
|
channel.Checks.ShouldBe(2);
|
|
channel.Downloads.ShouldBe(1);
|
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The look is a courtesy and never a gate. Somebody pressing RESTART NOW on a train has a build on disk
|
|
/// that is newer than the one running, and a forge that cannot be reached must not stand between them.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
channel.CheckFailure = new HttpRequestException("no such host");
|
|
|
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
|
|
|
channel.Restarts.ShouldBe(1);
|
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
|
updates.State.ShouldNotBe(UpdateState.Failed, "nothing the user asked for failed");
|
|
}
|
|
|
|
/// <inheritdoc cref="RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched" />
|
|
/// <remarks>
|
|
/// The same rule one step further in: the newer build was found and then could not be fetched. What is
|
|
/// on disk is untouched, so that is what gets installed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RestartingWhenTheNewerFetchFails_StillInstallsWhatWasAlreadyFetched()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
channel.Available = new AvailableUpdate("1.4.0");
|
|
channel.DownloadFailure = new HttpRequestException("the connection was reset");
|
|
|
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
|
|
|
channel.Restarts.ShouldBe(1);
|
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Everything the banner draws keys off <c>IsReady</c>, and the last look moves the state off it — so
|
|
/// without <c>IsRestarting</c> the strip vanished the moment RESTART NOW was pressed and left nothing on
|
|
/// screen saying why, for as long as a package takes to fetch.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheBannerStaysUpWhileARestartFetchesANewerBuild()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
|
|
await updates.CheckOnceAsync(Token);
|
|
|
|
channel.Available = new AvailableUpdate("1.4.0");
|
|
channel.HoldDownload = new TaskCompletionSource();
|
|
|
|
var pressed = updates.RestartNowCommand.ExecuteAsync(null);
|
|
|
|
// The gate is what makes the intermediate state observable; without it the fetch is over before
|
|
// anything could look at it.
|
|
while (updates.State is not UpdateState.Downloading)
|
|
{
|
|
await Task.Yield();
|
|
}
|
|
|
|
updates.IsBannerShowing.ShouldBeTrue();
|
|
updates.ReadyHeadline.ShouldContain("before restarting");
|
|
|
|
channel.HoldDownload.SetResult();
|
|
await pressed;
|
|
|
|
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The loop rather than <c>CheckOnceAsync</c>, which is the one thing the rest of this file avoids
|
|
/// driving — and here it is the whole point, because the claim is about when the first pass happens
|
|
/// rather than about what it does. The first pass used to wait two minutes, which meant a client opened
|
|
/// to reach one host and closed again never asked at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// It waits on the pass and not on a clock, so there is nothing here to be flaky about: a regression
|
|
/// that puts a delay back in front of the loop does not fail on a margin, it spins until the suite's own
|
|
/// cancellation ends it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheFirstPassRunsAtStart_RatherThanOnADelay()
|
|
{
|
|
channel.Available = new AvailableUpdate("1.3.0");
|
|
|
|
var updates = Build();
|
|
await using var _ = updates.ConfigureAwait(false);
|
|
|
|
updates.Start();
|
|
|
|
while (updates.State is not UpdateState.Ready)
|
|
{
|
|
Token.ThrowIfCancellationRequested();
|
|
|
|
await Task.Yield();
|
|
}
|
|
|
|
channel.Checks.ShouldBe(1);
|
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Started and disposed with nothing in between, which since the first pass stopped waiting two minutes
|
|
/// is a race rather than a formality: the loop may be anywhere between its yield and a finished check
|
|
/// when the cancellation lands. What is asserted is what matters either way — that disposing returns,
|
|
/// rather than waiting on a pass that will never be allowed to finish.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DisposingStopsTheLoop()
|
|
{
|
|
var updates = Build();
|
|
|
|
updates.Start();
|
|
|
|
await updates.DisposeAsync();
|
|
await updates.DisposeAsync();
|
|
}
|
|
}
|