Public Access
Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
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>
This commit is contained in:
1 parent
f7354aa7e8
commit
e0509c6679
8 files changed
+238
-29
No files matched your search
@@ -3538,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
|
|||||||
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
||||||
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
||||||
something wrote into the install directory at runtime and would survive a reinstall.
|
something wrote into the install directory at runtime and would survive a reinstall.
|
||||||
|
|
||||||
|
### 19.13 A copy at the same path that rpm does not own is offered nothing
|
||||||
|
|
||||||
|
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
|
||||||
|
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
|
||||||
|
|
||||||
|
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
|
||||||
|
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
|
||||||
|
stays empty however long it runs.
|
||||||
|
|
||||||
|
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
|
||||||
|
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
|
||||||
|
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
|
||||||
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
this.installedVersion = installedVersion;
|
this.installedVersion = installedVersion;
|
||||||
this.handToInstaller = handToInstaller;
|
this.handToInstaller = handToInstaller;
|
||||||
|
|
||||||
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
packageName = PackageFor(channel);
|
||||||
? "dodossh-nightly"
|
}
|
||||||
: "dodossh";
|
|
||||||
|
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
|
||||||
|
internal static IUpdateChannel ForInstall(string channel) =>
|
||||||
|
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
|
||||||
|
/// reach it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The path says where this copy is and only rpm says who put it there.</b>
|
||||||
|
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
|
||||||
|
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
|
||||||
|
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
|
||||||
|
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
|
||||||
|
/// again every six hours.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
|
||||||
|
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
|
||||||
|
/// already matched, and nothing at all anywhere else.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="channel">The build's channel, which names the package.</param>
|
||||||
|
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
|
||||||
|
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
|
||||||
|
internal static IUpdateChannel ForInstall(
|
||||||
|
string channel,
|
||||||
|
Func<string, string?> installedVersion,
|
||||||
|
Func<IUpdateChannel> whenOwned)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||||
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||||
|
ArgumentNullException.ThrowIfNull(whenOwned);
|
||||||
|
|
||||||
|
var package = PackageFor(channel);
|
||||||
|
|
||||||
|
return installedVersion(package) is null
|
||||||
|
? new UnavailableUpdateChannel(
|
||||||
|
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
|
||||||
|
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
|
||||||
|
+ "package manager that installed it.")
|
||||||
|
: whenOwned();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||||
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
|
||||||
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
|
||||||
/// composition rather than at every call site.
|
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
|
||||||
|
/// every call site.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because
|
||||||
|
// rpm answered, so silence now is a package removed under a running process or an rpm that
|
||||||
|
// stopped working — and any stand-in number would make every published build look newer and
|
||||||
|
// fetch a package for a machine that cannot install it, which is what this once did.
|
||||||
|
if (installedVersion(packageName) is not { } installed)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"rpm has no record of {packageName}, so there is no installed version to compare the "
|
||||||
|
+ $"{channel} release against.");
|
||||||
|
}
|
||||||
|
|
||||||
// The one place null is returned, and it means what null is documented to mean: this build is
|
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||||
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||||
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||||
: $"{RepositoryApi}/releases/latest";
|
: $"{RepositoryApi}/releases/latest";
|
||||||
|
|
||||||
|
/// <summary>The package a channel's builds are published as.</summary>
|
||||||
|
private static string PackageFor(string channel) =>
|
||||||
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||||
|
? "dodossh-nightly"
|
||||||
|
: "dodossh";
|
||||||
|
|
||||||
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||||
release?.Assets?.FirstOrDefault(
|
release?.Assets?.FirstOrDefault(
|
||||||
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||||
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
|
||||||
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
|
||||||
/// an install PackageKit declines rather than a security fix that never arrives.
|
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
|
||||||
|
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
|
||||||
|
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string? InstalledRpmVersion(string packageName)
|
private static string? InstalledRpmVersion(string packageName)
|
||||||
|
|||||||
@@ -66,21 +66,23 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
/// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
/// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
|
||||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
/// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
|
||||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
/// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
|
||||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
/// over them, and offering it would be an offer that cannot be carried out.
|
||||||
/// out.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
/// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
|
||||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
/// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
|
||||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
/// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
|
||||||
/// they are named in each other's comments.
|
/// and that copy was offered every nightly as an RPM it could never install. So a matching path is
|
||||||
|
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
|
||||||
|
/// only where the path already matched. The install root is a constant in two places — here and the
|
||||||
|
/// packaging script — and they are named in each other's comments.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[SupportedOSPlatform("linux")]
|
[SupportedOSPlatform("linux")]
|
||||||
@@ -91,7 +93,7 @@ internal static class UpdateChannels
|
|||||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
return string.Equals(here, root, StringComparison.Ordinal)
|
return string.Equals(here, root, StringComparison.Ordinal)
|
||||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
|
||||||
: new UnavailableUpdateChannel();
|
: new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,10 @@
|
|||||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="{Binding Updates.UnsupportedReason}"
|
||||||
|
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,32 @@ public interface IUpdateChannel
|
|||||||
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
||||||
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
|
||||||
|
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
|
||||||
|
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
|
||||||
|
/// wrong machine, and the person reading it needs telling which updater is theirs.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
||||||
{
|
{
|
||||||
|
/// <summary>Creates one for the situations that need no telling apart.</summary>
|
||||||
|
public UnavailableUpdateChannel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
|
||||||
|
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
|
||||||
|
public UnavailableUpdateChannel(string reason)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||||
|
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
|
||||||
|
public string? Reason { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => false;
|
public bool IsSupported => false;
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
this.restart = restart;
|
this.restart = restart;
|
||||||
|
|
||||||
CurrentVersion = updates.CurrentVersion;
|
CurrentVersion = updates.CurrentVersion;
|
||||||
|
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
|
||||||
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
||||||
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
/// <summary>What this build calls itself.</summary>
|
/// <summary>What this build calls itself.</summary>
|
||||||
internal string CurrentVersion { get; }
|
internal string CurrentVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
|
||||||
|
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? UnsupportedReason { get; }
|
||||||
|
|
||||||
|
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
|
||||||
|
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private UpdateState state;
|
private UpdateState state;
|
||||||
|
|
||||||
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsSupported));
|
OnPropertyChanged(nameof(IsSupported));
|
||||||
OnPropertyChanged(nameof(IsUnsupported));
|
OnPropertyChanged(nameof(IsUnsupported));
|
||||||
|
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
|
||||||
OnPropertyChanged(nameof(IsChecking));
|
OnPropertyChanged(nameof(IsChecking));
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
OnPropertyChanged(nameof(IsReady));
|
OnPropertyChanged(nameof(IsReady));
|
||||||
|
|||||||
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A machine that cannot say what it has treats everything as newer.
|
/// A machine that cannot say what it has is told so, and is offered nothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
|
/// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
|
||||||
/// an install the package manager declines, and the cost of the other direction is a security fix that
|
/// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
|
||||||
/// never arrives.
|
/// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
|
||||||
|
/// could install. Unknown is not older.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
|
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
|
||||||
{
|
{
|
||||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
|
var handed = new List<string>();
|
||||||
|
var channel = Channel(
|
||||||
|
RandomNumberGenerator.GetBytes(16),
|
||||||
|
published: "0.0.1~alpha.0.200-1",
|
||||||
|
installed: null,
|
||||||
|
handToInstaller: handed.Add);
|
||||||
|
|
||||||
(await channel.CheckAsync(Token)).ShouldNotBeNull();
|
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
async () => await channel.DownloadAsync(
|
||||||
|
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
|
||||||
|
new Progress<int>(),
|
||||||
|
Token));
|
||||||
|
|
||||||
|
handed.ShouldBeEmpty();
|
||||||
|
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
|
||||||
|
/// spec. No binary and no such package both arrive here as null.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"nightly",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh-nightly"]);
|
||||||
|
updates.IsSupported.ShouldBeFalse();
|
||||||
|
|
||||||
|
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
|
||||||
|
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("outside the dodossh-nightly RPM");
|
||||||
|
reason.ShouldContain("package manager that installed it");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmOwns_GetsTheChannel()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
var owned = new FakeUpdateChannel();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"release",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return "0.0.1-1";
|
||||||
|
},
|
||||||
|
() => owned);
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh"]);
|
||||||
|
updates.ShouldBeSameAs(owned);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
Build().CurrentVersion.ShouldBe("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]
|
[Fact]
|
||||||
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user