Merge branch 'claude/distracted-ritchie-53fc70'

Bounds the renderer wait, so a WebView2 that never initialises reports itself
instead of hanging Connect with the busy flag stuck.

Conflict resolution, all of it in the App test suite, which main had changed
under the branch when sleepy-chebyshev landed:

- The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware
  page, and takes the branch's RendererTimeout on top. One second rather than
  the branch's 250 ms, because the timeout now also bounds FakeRenderer's own
  wait for the attach it just made.
- FakeRenderer arrived on main after the branch was cut and still called the
  no-argument WaitForRendererAsync. Both sides merged cleanly and left the build
  broken; it now passes its own token.
- ConnectingWithNoRenderer's remark claimed the suite never starts the workspace
  and never attaches a renderer. Both are false here, so it now says what is
  true of the test: it is the one connect test that attaches no renderer.
This commit is contained in:
2026-07-29 15:40:25 +02:00
7 changed files with 175 additions and 14 deletions
@@ -66,7 +66,7 @@ internal sealed partial class FakeRenderer : IAsyncDisposable
var renderer = new FakeRenderer(attached);
await workspace.WaitForRendererAsync().ConfigureAwait(false);
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(false);
return renderer;
}
@@ -61,6 +61,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
//
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
// reading them back out of the served page rather than by being handed the token.
//
// The renderer timeout is cut right down for the same reason: the tests that want a renderer attach
// one themselves, so a connect in a test that does not would wait the shipped fifteen seconds out
// in full, and that is fifteen seconds of a suite sitting still. A second rather than milliseconds
// because this bounds FakeRenderer's own wait too — an in-process loopback handshake that has
// already returned, so the margin is enormous, but not one worth making a loaded machine race for.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
@@ -72,7 +78,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
}),
ssh,
TimeProvider.System);
TimeProvider.System,
new TerminalWorkspaceOptions { RendererTimeout = TimeSpan.FromSeconds(1) });
// Started, as the application does immediately after composing it. Without the accept loop the
// page is never served, so nothing could attach a renderer.
@@ -597,6 +604,30 @@ public sealed class ShellFlowTests : IAsyncLifetime
server.PushCount.ShouldBe(0);
}
/// <remarks>
/// The one connect test that deliberately attaches no <see cref="FakeRenderer"/>: the listener is up
/// and nothing ever connects to it, which from the view model's side is indistinguishable from a
/// WebView2 that failed to initialise on a user's machine. The interesting assertion is the second
/// one: while the wait was unbounded this hung with the busy flag set, so the window stayed disabled
/// and said "Connecting…" for the rest of the session.
/// </remarks>
[Fact]
public async Task ConnectingWithNoRenderer_ExplainsItselfAndReleasesTheWindow()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
await vault.ConnectCommand.ExecuteAsync(null);
// Naming the runtime is the whole point: a bare "The operation has timed out" sends someone
// looking at their network or their host.
vault.Status.ShouldContain("WebView2");
vault.IsBusy.ShouldBeFalse("a connect that gave up must not leave the window disabled");
}
[Fact]
public async Task LockingForgetsTheVault()
{