Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 |
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||
|
||||
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||
|
||||
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||
NU1004 before a single file is compiled.
|
||||
|
||||
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||
request went red together, on
|
||||
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
|
||||
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||
time anything ran.
|
||||
|
||||
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||
|
||||
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||
same ceremony any other dependency bump gets.
|
||||
|
||||
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||
Directory.Build.props is being evaluated.
|
||||
|
||||
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||
analysed is a real way to miss a trim warning.
|
||||
-->
|
||||
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||
<!--
|
||||
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||
unconditioned Update it would have to become rewrites every framework's entry.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||
ILLinkPackVersion="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -2424,7 +2424,9 @@ script warns rather than failing when that is legitimate, which is the first rel
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
|
||||
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
|
||||
so a client started after a release finds it without anybody asking.
|
||||
|
||||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||
|
||||
@@ -87,12 +87,27 @@
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -2141,8 +2141,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
/// <remarks>
|
||||
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
|
||||
/// that merely contains it, and both come before a match found only in the address. Typing three
|
||||
/// characters of a name people use daily should not put that host third.
|
||||
/// that merely contains it, both come before a match found only in the address, and all three come
|
||||
/// before one found only in the vault's name. Typing three characters of a name people use daily should
|
||||
/// not put that host third. See <see cref="Rank"/>.
|
||||
/// </remarks>
|
||||
private void RefreshSearchResults()
|
||||
{
|
||||
@@ -2175,6 +2176,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The vault comes last, and only when the row prints it.</b> Its name is the widest of the three
|
||||
/// readings — one word can match every host in a team's vault at once — so a host whose own name or
|
||||
/// address answers the query has to come first, or typing a machine's name would bury it under everybody
|
||||
/// else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="HostRowViewModel.HasVaultBadge"/> rather than the name itself, so this matches exactly what
|
||||
/// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing
|
||||
/// displays would answer a query with a list whose reason for existing is invisible — type "personal"
|
||||
/// there and every host comes back for no reason anybody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int Rank(HostRowViewModel host, string query)
|
||||
{
|
||||
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
@@ -2187,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return 1;
|
||||
}
|
||||
|
||||
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 2
|
||||
if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return host.HasVaultBadge
|
||||
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 3
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,16 +74,6 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>How long to wait before the first pass.</summary>
|
||||
/// <remarks>
|
||||
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
|
||||
/// user is waiting for: a vault edited on another machine should be current by the time they have
|
||||
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
|
||||
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
|
||||
/// sign-in and a first sync, at the one moment somebody is watching the window.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly IUpdateChannel updates;
|
||||
private readonly ClientSettingsStore settings;
|
||||
private readonly TimeProvider clock;
|
||||
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
loop = RunCheckLoopAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
|
||||
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
|
||||
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
|
||||
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
|
||||
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
|
||||
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
|
||||
/// Every start now asks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
|
||||
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
|
||||
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
|
||||
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
|
||||
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||
await Task.Yield();
|
||||
|
||||
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||
|
||||
do
|
||||
{
|
||||
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
|
||||
// the loop is waiting to be handed back the thread has to be observed here rather than
|
||||
// only at the next tick. Otherwise an application closed during launch spends its last
|
||||
// moment asking a release channel about a build it is not going to run.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
||||
|
||||
@@ -84,14 +84,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
||||
const root = document.getElementById('root');
|
||||
const statusBanner = document.getElementById('status');
|
||||
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
||||
const sessions = new Map();
|
||||
|
||||
/** @type {WebSocket | null} */
|
||||
let socket = null;
|
||||
|
||||
function setStatus(text) {
|
||||
statusBanner.textContent = text ?? '';
|
||||
/** Whose pane is showing, or null before there is one — see activate(). */
|
||||
let activeSessionId = null;
|
||||
|
||||
/*
|
||||
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
||||
There is one #status element for the whole page, because there is one page for every terminal: the
|
||||
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
||||
two sources that are not the same size, and the difference is the whole of this.
|
||||
|
||||
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
||||
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
||||
|
||||
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
||||
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
||||
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
||||
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
||||
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
||||
|
||||
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
||||
live output on any pane, which makes what became of one session the less urgent of the two.
|
||||
*/
|
||||
let transportStatus = statusBanner.textContent ?? '';
|
||||
|
||||
function renderStatus() {
|
||||
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
||||
|
||||
statusBanner.textContent = transportStatus || notice;
|
||||
}
|
||||
|
||||
/** Says something about the socket, which every pane shares. */
|
||||
function setTransportStatus(text) {
|
||||
transportStatus = text ?? '';
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
||||
function setSessionNotice(sessionId, text) {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.notice = text ?? '';
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||
@@ -292,7 +335,7 @@ function createSession(sessionId) {
|
||||
|
||||
term.onResize(() => sendResize(sessionId, term, pane));
|
||||
|
||||
const session = { term, fit, pane };
|
||||
const session = { term, fit, pane, notice: '' };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
activate(sessionId);
|
||||
@@ -306,6 +349,11 @@ function activate(sessionId) {
|
||||
session.pane.dataset.active = String(id === sessionId);
|
||||
}
|
||||
|
||||
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
||||
// session that was showing had to say for its own, which is the point of holding it per session.
|
||||
activeSessionId = sessionId;
|
||||
renderStatus();
|
||||
|
||||
const active = sessions.get(sessionId);
|
||||
if (active) {
|
||||
active.term.focus();
|
||||
@@ -373,7 +421,10 @@ function handleFrame(buffer) {
|
||||
session.term.write(REPLAY_BANNER);
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
// This session's own line, and only this one's: a session that is open has nothing to say about
|
||||
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
||||
// before any frame can arrive.
|
||||
setSessionNotice(sessionId, '');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -426,7 +477,14 @@ function handleFrame(buffer) {
|
||||
session.pane.remove();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
setStatus('');
|
||||
// The notice went with the session record it was held on, but the page can still be pointing at
|
||||
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
||||
// closed tab while the host decides which pane to show next.
|
||||
if (activeSessionId === sessionId) {
|
||||
activeSessionId = null;
|
||||
}
|
||||
|
||||
renderStatus();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -503,14 +561,19 @@ function handleFrame(buffer) {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
if (session) {
|
||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||
// remote said, and that is usually why the session ended.
|
||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||
session.term.options.cursorBlink = false;
|
||||
if (!session) {
|
||||
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
||||
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
||||
// be showing at the time.
|
||||
break;
|
||||
}
|
||||
|
||||
setStatus(reason);
|
||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||
// remote said, and that is usually why the session ended.
|
||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||
session.term.options.cursorBlink = false;
|
||||
|
||||
setSessionNotice(sessionId, reason);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -535,7 +598,7 @@ function scheduleReconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Reconnecting the terminal view…');
|
||||
setTransportStatus('Reconnecting the terminal view…');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -555,7 +618,7 @@ function connect() {
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('');
|
||||
setTransportStatus('');
|
||||
|
||||
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
||||
// attempts within one outage, reset once the outage is actually over.
|
||||
|
||||
@@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
|
||||
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
|
||||
/// and Enter takes whichever the ranking happened to put first.
|
||||
/// <para>
|
||||
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
|
||||
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
|
||||
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultSaysWhichVaultItCameOutOf()
|
||||
{
|
||||
await SeedSharedHostAsync();
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "prod-db";
|
||||
Relayout(window);
|
||||
|
||||
var found = shell.SearchResults.ShouldHaveSingleItem();
|
||||
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
|
||||
|
||||
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
|
||||
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
|
||||
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
|
||||
/// than leave a gap above the kind word.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
|
||||
{
|
||||
await OnThePaletteAsync((palette, _) =>
|
||||
{
|
||||
var first = shell.SearchResults[0];
|
||||
|
||||
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
|
||||
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
|
||||
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
|
||||
@@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => ReferenceEquals(item.DataContext, host));
|
||||
|
||||
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
|
||||
private static List<string> VisibleTexts(Visual row) =>
|
||||
row.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.Where(text => text.IsEffectivelyVisible)
|
||||
.Select(text => text.Text ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
|
||||
/// <remarks>
|
||||
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
|
||||
/// finds nothing to look at.
|
||||
/// </remarks>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
}
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
@@ -325,4 +389,37 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a second, shared vault to the fixture's session and files one host into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
|
||||
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
|
||||
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
|
||||
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
|
||||
/// </remarks>
|
||||
private async Task SeedSharedHostAsync()
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
var shared = await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1765,6 +1765,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches a host's vault name, and only where the row prints it — which a session holding
|
||||
/// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked
|
||||
/// behind nothing and explained by nothing on screen. See <c>MainWindowViewModel.Rank</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.VaultName.ShouldBe("Personal", "which is the word this test types");
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
|
||||
shell.SearchResults.ShouldHaveSingleItem()
|
||||
.HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart");
|
||||
|
||||
shell.SearchText = "personal";
|
||||
|
||||
shell.SearchResults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
|
||||
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
|
||||
|
||||
@@ -403,6 +403,46 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
channel.Checks.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
|
||||
@@ -766,6 +766,59 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("left alone", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The palette reaches every vault the session holds a key for, so the vault's name is one of the things
|
||||
/// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two
|
||||
/// vaults invites, and until now the only answer was to know one of the machines by name already.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The order is the assertion that matters. A vault name is the widest of the three readings — one word
|
||||
/// matches every host in that vault at once — so the host whose own name carries the word has to come
|
||||
/// first, or typing a machine's name would bury it under everybody else's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "platform-gateway";
|
||||
vault.EditorHostname = "gateway.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorSelectedVault =
|
||||
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
shell.SearchText = "platform";
|
||||
|
||||
shell.SearchResults
|
||||
.Select(row => row.Label)
|
||||
.ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's");
|
||||
|
||||
shell.SearchResults[1].VaultBadge.ShouldBe(
|
||||
"PLATFORM SECRETS", "and the row says what it was found by");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user