// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers.Binary;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Aspire.Dashboard.Tests.Shared;
using Grpc.Core;
using Hex1b.Input;
using Xunit;
namespace Aspire.Dashboard.Tests.Terminal;
public class TerminalWebSocketTests(ITestOutputHelper output)
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_ReflowsRetainedHistoryAndPreservesSoftWrapsAfterReconnect(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var browser = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
await SendAsync(browser, """{"type":"requestPrimary","columns":100,"rows":30}""", timeout.Token);
await ReadUntilAsync(browser, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token);
var text = new string('A', 19) + "\u754ce\u0301" + new string('B', 43) + "-END";
var lines = Enumerable.Range(0, 35).Select(i => $"{i:D2}:{text}").ToArray();
host.Workload.Write(string.Join("\r\n", lines) + "\r\nready");
await ReadUntilAsync(browser, frame => frame.GetProperty("history").GetProperty("totalRows").GetInt32() >= 36, timeout.Token);
var requestId = 1;
foreach (var width in new[] { 20, 40, 100 })
{
await SendAsync(browser, JsonSerializer.Serialize(new { type = "resize", columns = width, rows = 30 }), timeout.Token);
// Each line occupies 72 cells: the wide glyph and combining mark cancel in the UTF-16 length.
var expectedRows = lines.Length * ((72 + width - 1) / width) + 1;
var resized = await ReadUntilAsync(browser, frame => frame.GetProperty("columns").GetInt32() == width &&
frame.GetProperty("history").GetProperty("totalRows").GetInt32() == expectedRows, timeout.Token);
Assert.Equal(expectedRows, resized.GetProperty("history").GetProperty("totalRows").GetInt32());
Assert.Equal(lines[0], await ReadFirstLogicalLineAsync(browser, requestId, timeout.Token));
requestId += 4;
}
// Clear the screen, then leave a complete wrapped logical line on it for a fresh peer's replay.
host.Workload.Write("\u001b[3J\u001b[2J\u001b[H" + text + "\r\nreconnect-ready");
await host.WaitForProducerTextAsync("reconnect-ready", timeout.Token);
await SendAsync(browser, """{"type":"resize","columns":20,"rows":30}""", timeout.Token);
await ReadUntilAsync(browser, frame => frame.GetProperty("columns").GetInt32() == 20, timeout.Token);
await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token);
using var reconnected = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(reconnected, _ => true, timeout.Token);
Assert.Equal(text, await ReadFirstLogicalLineAsync(reconnected, 1, timeout.Token));
await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_PreservesRemotePrimaryAndResizeAcrossReconnect(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var first = await host.ConnectBrowserAsync(timeout.Token);
var initial = await ReadUntilAsync(first, frame => frame.GetProperty("peer").GetProperty("id").ValueKind == JsonValueKind.String, timeout.Token);
Assert.Equal(100, initial.GetProperty("columns").GetInt32());
Assert.Equal(30, initial.GetProperty("rows").GetInt32());
// A browser resize cannot seize the remote producer's primary role.
await SendAsync(first, """{"type":"resize","columns":80,"rows":24}""", timeout.Token);
await SendAsync(first, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token);
var primary = await ReadUntilAsync(first, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token);
Assert.Equal(80, primary.GetProperty("columns").GetInt32());
Assert.Equal(24, primary.GetProperty("rows").GetInt32());
Assert.Equal(primary.GetProperty("peer").GetProperty("id").GetString(), host.Presentation.PrimaryPeerId);
using var second = await host.ConnectBrowserAsync(timeout.Token);
var viewer = await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("id").ValueKind == JsonValueKind.String, timeout.Token);
Assert.False(viewer.GetProperty("peer").GetProperty("isPrimary").GetBoolean());
Assert.Equal(80, viewer.GetProperty("columns").GetInt32());
await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token);
await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("primaryId").ValueKind == JsonValueKind.Null, timeout.Token);
await SendAsync(second, """{"type":"requestPrimary","columns":132,"rows":30}""", timeout.Token);
var takeover = await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token);
Assert.Equal(132, takeover.GetProperty("columns").GetInt32());
await second.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_ReassemblesFragmentedUtf8Input(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var browser = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
var message = Encoding.UTF8.GetBytes("{\"type\":\"input\",\"text\":\"hello \u00e9\"}");
var split = Array.IndexOf(message, (byte)0xc3) + 1;
await browser.SendAsync(message.AsMemory(0, split), WebSocketMessageType.Text, false, timeout.Token);
await browser.SendAsync(message.AsMemory(split), WebSocketMessageType.Text, true, timeout.Token);
var input = new StringBuilder();
while (input.Length < "hello \u00e9".Length)
{
var inputEvent = await host.Workload.InputEvents.ReadAsync(timeout.Token);
if (inputEvent is Hex1bKeyEvent key)
{
input.Append(key.Text);
}
}
Assert.Equal("hello \u00e9", input.ToString());
await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task BrowserView_RejectsInvalidMessageTypeOrOversizedInput(bool oversized, bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var browser = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
if (oversized)
{
await browser.SendAsync(new byte[64 * 1024], WebSocketMessageType.Text, false, timeout.Token);
}
else
{
await browser.SendAsync(new byte[] { 1 }, WebSocketMessageType.Binary, true, timeout.Token);
}
var buffer = new byte[64 * 1024];
WebSocketReceiveResult result;
do
{
result = await browser.ReceiveAsync(buffer, timeout.Token);
}
while (result.MessageType != WebSocketMessageType.Close);
Assert.Equal(WebSocketCloseStatus.PolicyViolation, result.CloseStatus);
}
[Theory]
[InlineData("\u001bP7;1q\"1;1;2;6#1;2;100;0;0#1BB\u001b\\", false)]
[InlineData("\u001bP7;1q\"1;1;2;6#1;2;100;0;0#1BB\u001b\\", true)]
[InlineData("\u001b_Ga=T,f=32,s=1,v=1,i=7,p=11,C=1,q=2;/wAA/w==\u001b\\", false)]
[InlineData("\u001b_Ga=T,f=32,s=1,v=1,i=7,p=11,C=1,q=2;/wAA/w==\u001b\\", true)]
public async Task BrowserView_ProjectsSixelAndKittyGraphics(string sequence, bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var browser = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
host.Workload.Write(sequence);
var graphics = await ReadUntilAsync(browser, frame => frame.GetProperty("placements").GetArrayLength() > 0, timeout.Token);
Assert.Single(graphics.GetProperty("placements").EnumerateArray());
Assert.Single(graphics.GetProperty("images").EnumerateArray());
await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
using var reconnected = await host.ConnectBrowserAsync(timeout.Token);
var restored = await ReadUntilAsync(reconnected, frame => frame.GetProperty("placements").GetArrayLength() > 0, timeout.Token);
Assert.Single(restored.GetProperty("placements").EnumerateArray());
Assert.Single(restored.GetProperty("images").EnumerateArray());
await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task BrowserView_PreservesKittyImageForLatePeerAndPlacementUpdates(bool alternateScreen, bool nativeSize)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var first = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(first, _ => true, timeout.Token);
if (alternateScreen)
{
host.Workload.Write("\u001b[?1049h");
}
// Kitty transmits pixels with a=t, then reuses the image id in a=p
// placement commands that contain no image data. Like KgpCloudDemo,
// synchronized frames use lowercase d=a to remove placements, not pixels.
// https://sw.kovidgoyal.net/kitty/graphics-protocol/#displaying-images-on-screen
var sizing = nativeSize ? "" : ",c=2,r=2";
host.Workload.Write("\u001b[?2026h\u001b_Ga=t,f=32,t=d,s=1,v=1,i=7300,q=2;/wAA/w==\u001b\\" +
"\u001b_Ga=d,d=a,q=2\u001b\\" +
$"\u001b[2;3H\u001b_Ga=p,i=7300{sizing},C=1,q=2\u001b\\\u001b[?2026l");
var initial = await ReadUntilAsync(first, HasPlacement, timeout.Token);
AssertImageIncluded(initial);
using var second = await host.ConnectBrowserAsync(timeout.Token);
var late = await ReadUntilAsync(second, HasPlacement, timeout.Token);
AssertImageIncluded(late);
Assert.Equal(initial.GetProperty("placements")[0].GetProperty("x").GetDouble(),
late.GetProperty("placements")[0].GetProperty("x").GetDouble());
host.Workload.Write("\u001b[?2026h\u001b_Ga=d,d=a,q=2\u001b\\" +
$"\u001b[2;8H\u001b_Ga=p,i=7300{sizing},C=1,q=2\u001b\\\u001b[?2026l");
var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble();
var updates = await Task.WhenAll(
ReadUntilAsync(first, HasMovedPlacement, timeout.Token),
ReadUntilAsync(second, HasMovedPlacement, timeout.Token));
Assert.Equal(updates[0].GetProperty("placements")[0].GetProperty("x").GetDouble(),
updates[1].GetProperty("placements")[0].GetProperty("x").GetDouble());
await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token);
using var reconnected = await host.ConnectBrowserAsync(timeout.Token);
var restored = await ReadUntilAsync(reconnected, HasMovedPlacement, timeout.Token);
AssertImageIncluded(restored);
await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await second.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
static bool HasPlacement(JsonElement frame) => frame.GetProperty("placements").GetArrayLength() == 1;
bool HasMovedPlacement(JsonElement frame) =>
HasPlacement(frame) && frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX;
static void AssertImageIncluded(JsonElement frame)
{
var image = Assert.Single(frame.GetProperty("images").EnumerateArray());
Assert.Equal(1, image.GetProperty("width").GetInt32());
Assert.Equal(1, image.GetProperty("height").GetInt32());
Assert.Equal(4, image.GetProperty("byteLength").GetInt32());
Assert.Equal(image.GetProperty("key").GetString(), frame.GetProperty("placements")[0].GetProperty("key").GetString());
}
}
[Fact]
public async Task BrowserView_AttachingDuringKittyPlacementReplacementRetainsPixels()
{
await using var host = new TerminalTestHost(output, requireAuthentication: false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var first = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(first, _ => true, timeout.Token);
host.Workload.Write("\u001b[?1049h\u001b_Ga=t,f=32,t=d,s=1,v=1,i=7300,q=2;/wAA/w==\u001b\\" +
"\u001b[2;3H\u001b_Ga=p,i=7300,C=1,q=2\u001b\\");
var initial = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token);
var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble();
// An animation can clear placements inside a synchronized-output frame
// before emitting replacements. Lowercase d=a must leave the uploaded
// pixels available to viewers that attach during that interval.
host.Workload.Write("\u001b[?2026h\u001b_Ga=d,d=a,q=2\u001b\\\u001b[Hpalette-cleared");
await host.WaitForProducerTextAsync("palette-cleared", timeout.Token);
using var late = await host.ConnectBrowserAsync(timeout.Token);
await host.WaitForPeerHandshakesAsync(timeout.Token);
host.Workload.Write("\u001b[2;8H\u001b_Ga=p,i=7300,C=1,q=2\u001b\\\u001b[?2026l");
var original = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1 &&
frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX, timeout.Token);
var restored = await ReadUntilAsync(late, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token);
var image = Assert.Single(restored.GetProperty("images").EnumerateArray());
Assert.Equal(4, image.GetProperty("byteLength").GetInt32());
Assert.Equal(original.GetProperty("placements")[0].GetProperty("x").GetDouble(),
restored.GetProperty("placements")[0].GetProperty("x").GetDouble());
await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await late.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
[InlineData(12)]
[InlineData(27)]
public async Task BrowserView_AttachingDuringKittyPlacementCommandReplaysCompleteSequence(int splitIndex)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var first = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(first, _ => true, timeout.Token);
host.Workload.Write("\u001b[?1049h\u001b_Ga=T,f=32,t=d,s=1,v=1,i=7300,p=11,C=1,q=2;/wAA/w==\u001b\\");
var initial = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token);
var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble();
// PTY reads can split ESC_Ga=p,...ESC\ within its introducer, fields or
// terminator. A new HMP peer needs that incomplete parser prefix as well
// as the screen checkpoint, or the suffix becomes ordinary screen text.
const string placement = "\u001b_Ga=p,i=7300,p=11,C=1,q=2\u001b\\";
host.Workload.Write("\u001b[Hprefix-ready\u001b[2;8H" + placement[..splitIndex]);
await host.WaitForProducerTextAsync("prefix-ready", timeout.Token);
using var late = await host.ConnectBrowserAsync(timeout.Token);
await host.WaitForPeerHandshakesAsync(timeout.Token);
host.Workload.Write(placement[splitIndex..]);
var updates = await Task.WhenAll(
ReadUntilAsync(first, HasMovedPlacement, timeout.Token),
ReadUntilAsync(late, HasMovedPlacement, timeout.Token));
Assert.Equal(updates[0].GetProperty("placements")[0].GetProperty("x").GetDouble(),
updates[1].GetProperty("placements")[0].GetProperty("x").GetDouble());
await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await late.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
bool HasMovedPlacement(JsonElement frame) =>
frame.GetProperty("placements").GetArrayLength() == 1 &&
frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX;
}
[Fact]
public async Task BrowserView_PreservesHyperlinkDestinationChangesAcrossReconnect()
{
await using var host = new TerminalTestHost(output, requireAuthentication: false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var browser = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
// OSC 8: ESC ] 8 ; parameters ; URI ST text ESC ] 8 ; ; ST.
// Replacing only the destination must update HWT metadata even when
// the visible cells remain identical.
host.Workload.Write("\u001b[H\u001b]8;;https://example.com/first\u001b\\link\u001b]8;;\u001b\\");
var initial = await ReadUntilAsync(browser, frame => frame.GetProperty("hyperlinks").GetArrayLength() > 0, timeout.Token);
AssertLink(initial, "https://example.com/first");
host.Workload.Write("\u001b[H\u001b]8;;https://example.com/second\u001b\\link\u001b]8;;\u001b\\");
var changed = await ReadUntilAsync(browser, frame => frame.GetProperty("hyperlinks").EnumerateArray()
.Any(link => link.GetProperty("uri").GetString() == "https://example.com/second"), timeout.Token);
AssertLink(changed, "https://example.com/second");
await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token);
using var reconnected = await host.ConnectBrowserAsync(timeout.Token);
var restored = await ReadUntilAsync(reconnected, frame => frame.GetProperty("hyperlinks").GetArrayLength() > 0, timeout.Token);
AssertLink(restored, "https://example.com/second");
await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
static void AssertLink(JsonElement frame, string uri)
{
var link = Assert.Single(frame.GetProperty("hyperlinks").EnumerateArray());
Assert.Equal(uri, link.GetProperty("uri").GetString());
Assert.Equal(0, link.GetProperty("row").GetInt32());
Assert.Equal(0, link.GetProperty("startColumn").GetInt32());
Assert.Equal(4, link.GetProperty("endColumn").GetInt32());
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_RequiresAuthenticationBeforeConnectingToProducer(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: true, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
await Assert.ThrowsAsync<WebSocketException>(() => host.ConnectBrowserAsync(timeout.Token));
Assert.Equal(0, host.ConnectionCount);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_DetachingReleasesViewerWithoutStoppingProducer(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly: false);
using var first = await host.ConnectBrowserAsync(session, timeout.Token);
await ReadUntilAsync(first, _ => true, timeout.Token);
await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Detach", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
Assert.Equal(WebSocketCloseStatus.NormalClosure, first.CloseStatus);
Assert.False(session.Ended.IsCompleted);
host.Workload.Write("producer-survived-detach");
await host.WaitForProducerTextAsync("producer-survived-detach", timeout.Token);
using var second = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("id").ValueKind == JsonValueKind.String, timeout.Token);
Assert.Equal(2, host.ConnectionCount);
await SendAsync(second, """{"type":"input","text":"x"}""", timeout.Token);
Hex1bEvent input;
do
{
input = await host.Workload.InputEvents.ReadAsync(timeout.Token);
}
while (input is not Hex1bKeyEvent);
Assert.Equal("x", ((Hex1bKeyEvent)input).Text);
await second.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
Assert.Equal(useGrpc ? 2 : 0, host.DisposedAttachments);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task BrowserView_ReadOnlyPolicyChangesWithoutReconnect(bool useGrpc, bool initiallyReadOnly)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly: initiallyReadOnly);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
session.ReadOnly = true;
host.Workload.Write("\u001b[?1000h\u001b[?1006h");
await SendAsync(browser, """{"type":"input","text":"blocked-input"}""", timeout.Token);
await SendAsync(browser, """{"type":"paste","text":"blocked-paste"}""", timeout.Token);
// These valid commands must pass native validation but not reach the producer.
await SendAsync(browser, """{"type":"key","key":"Enter","ctrl":false,"alt":false,"shift":false}""", timeout.Token);
await SendAsync(browser, """{"type":"mouse","action":"down","button":"left","x":1,"y":1}""", timeout.Token);
await SendAsync(browser, """{"type":"resize","columns":80,"rows":24}""", timeout.Token);
await SendAsync(browser, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token);
await SendAsync(browser, """{"type":"resync"}""", timeout.Token);
// A full resync is an ordered barrier after the rejected commands. ACK
// and rendering must remain functional while workload input is disabled.
var readOnly = await ReadUntilAsync(browser, frame => frame.GetProperty("full").GetBoolean(), timeout.Token);
Assert.Equal(100, readOnly.GetProperty("columns").GetInt32());
Assert.Equal(30, readOnly.GetProperty("rows").GetInt32());
Assert.False(readOnly.GetProperty("peer").GetProperty("isPrimary").GetBoolean());
session.ReadOnly = false;
await SendAsync(browser, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token);
var primary = await ReadUntilAsync(browser, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token);
Assert.Equal(80, primary.GetProperty("columns").GetInt32());
Assert.Equal(24, primary.GetProperty("rows").GetInt32());
await SendAsync(browser, """{"type":"input","text":"allowed"}""", timeout.Token);
var input = new StringBuilder();
while (!input.ToString().EndsWith("allowed", StringComparison.Ordinal))
{
var inputEvent = await host.Workload.InputEvents.ReadAsync(timeout.Token);
Assert.IsNotType<Hex1bMouseEvent>(inputEvent);
if (inputEvent is Hex1bKeyEvent key)
{
input.Append(key.Text);
}
}
Assert.Equal("allowed", input.ToString());
Assert.Equal(1, host.ConnectionCount);
await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_ReadOnlyPolicyIsLimitedToOneView(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly: true);
using var readOnly = await host.ConnectBrowserAsync(session, timeout.Token);
await ReadUntilAsync(readOnly, _ => true, timeout.Token);
using var interactive = await host.ConnectBrowserAsync(timeout.Token);
await ReadUntilAsync(interactive, _ => true, timeout.Token);
await SendAsync(interactive, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token);
var primary = await ReadUntilAsync(interactive, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token);
var peerId = primary.GetProperty("peer").GetProperty("id").GetString();
await ReadUntilAsync(readOnly, frame => frame.GetProperty("peer").GetProperty("primaryId").GetString() == peerId, timeout.Token);
await SendAsync(readOnly, """{"type":"requestPrimary","columns":120,"rows":40}""", timeout.Token);
await SendAsync(readOnly, """{"type":"paste","text":"blocked"}""", timeout.Token);
await SendAsync(readOnly, """{"type":"resync"}""", timeout.Token);
var unchanged = await ReadUntilAsync(readOnly, frame => frame.GetProperty("full").GetBoolean(), timeout.Token);
Assert.Equal(80, unchanged.GetProperty("columns").GetInt32());
Assert.Equal(24, unchanged.GetProperty("rows").GetInt32());
Assert.False(unchanged.GetProperty("peer").GetProperty("isPrimary").GetBoolean());
Assert.Equal(peerId, host.Presentation.PrimaryPeerId);
await SendAsync(interactive, """{"type":"input","text":"x"}""", timeout.Token);
Hex1bEvent input;
do
{
input = await host.Workload.InputEvents.ReadAsync(timeout.Token);
}
while (input is not Hex1bKeyEvent);
Assert.Equal("x", ((Hex1bKeyEvent)input).Text);
await readOnly.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await interactive.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
}
[Theory]
[InlineData(false, """{"type":"unknown"}""")]
[InlineData(true, """{"type":"unknown"}""")]
[InlineData(false, """{"type":"key"}""")]
[InlineData(true, """{"type":"key"}""")]
[InlineData(false, """{"type":"mouse"}""")]
[InlineData(true, """{"type":"mouse"}""")]
[InlineData(false, """{"type":"input","text":42}""")]
[InlineData(true, """{"type":"input","text":42}""")]
[InlineData(false, "{")]
[InlineData(true, "{")]
public async Task BrowserView_NativeValidationRejectsInvalidCommandsEvenWhenReadOnly(bool readOnly, string command)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
await ReadUntilAsync(browser, _ => true, timeout.Token);
await SendAsync(browser, command, timeout.Token);
var close = await ReadCloseAsync(browser, timeout.Token);
Assert.Equal(WebSocketCloseStatus.PolicyViolation, close.CloseStatus);
Assert.False(session.Ended.IsCompleted);
await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
}
[Theory]
[InlineData(StatusCode.NotFound, false)]
[InlineData(StatusCode.NotFound, true)]
[InlineData(StatusCode.FailedPrecondition, false)]
[InlineData(StatusCode.FailedPrecondition, true)]
public async Task BrowserView_MissingAppHostTerminalClosesWithoutHwtFrame(StatusCode status, bool duringHandshake)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true)
{
AttachmentFailureStatus = status,
FailAttachmentDuringHandshake = duringHandshake
};
using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(startup.Token);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var session = host.CreateViewSession(readOnly: false);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
var result = await browser.ReceiveAsync(new byte[64], timeout.Token);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal((WebSocketCloseStatus)4000, result.CloseStatus);
Assert.Equal("Terminal ended", result.CloseStatusDescription);
await session.Ended.WaitAsync(timeout.Token);
Assert.True(session.ReadOnly);
await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token);
await host.WaitForDisposedAttachmentsAsync(timeout.Token);
Assert.Equal(0, host.ConnectionCount);
Assert.Equal(duringHandshake ? 1 : 0, host.DisposedAttachments);
}
[Theory]
[InlineData(StatusCode.Unavailable, false)]
[InlineData(StatusCode.Unavailable, true)]
[InlineData(StatusCode.DeadlineExceeded, false)]
[InlineData(StatusCode.DeadlineExceeded, true)]
public async Task BrowserView_TransientAppHostAttachmentFailureRemainsRetryable(StatusCode status, bool duringHandshake)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true)
{
AttachmentFailureStatus = status,
FailAttachmentDuringHandshake = duringHandshake
};
using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(startup.Token);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var session = host.CreateViewSession(readOnly: false);
var exception = await Assert.ThrowsAsync<WebSocketException>(() => host.ConnectBrowserAsync(session, timeout.Token));
Assert.Contains("503", exception.Message);
Assert.False(session.Ended.IsCompleted);
Assert.False(session.ReadOnly);
await host.WaitForDisposedAttachmentsAsync(timeout.Token);
Assert.Equal(0, host.ConnectionCount);
Assert.Equal(duringHandshake ? 1 : 0, host.DisposedAttachments);
}
[Fact]
public async Task BrowserView_AppHostEndBeforeHandshakeClosesWithoutHwtFrame()
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
await host.EndTerminalAsync(includeHmpExit: false);
using var session = host.CreateViewSession(readOnly: false);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
var result = await browser.ReceiveAsync(new byte[64], timeout.Token);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal((WebSocketCloseStatus)4000, result.CloseStatus);
await session.Ended.WaitAsync(timeout.Token);
Assert.True(session.ReadOnly);
await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
Assert.Equal(0, host.ConnectionCount);
await host.WaitForDisposedAttachmentsAsync(timeout.Token);
Assert.Equal(1, host.DisposedAttachments);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task BrowserView_AppHostEndClosesWithCompletionStatusAndReleasesMirror(bool includeHmpExit, bool acknowledgeInitialFrame)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly: false);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
if (acknowledgeInitialFrame)
{
await ReadUntilAsync(browser, _ => true, timeout.Token);
}
else
{
var buffer = new byte[64 * 1024];
WebSocketReceiveResult frame;
do
{
frame = await browser.ReceiveAsync(buffer, timeout.Token);
Assert.Equal(WebSocketMessageType.Binary, frame.MessageType);
}
while (!frame.EndOfMessage);
}
await host.EndTerminalAsync(includeHmpExit);
await host.WaitForEndedObservedAsync(timeout.Token);
await session.Ended.WaitAsync(timeout.Token);
var close = await ReadCloseAsync(browser, timeout.Token);
Assert.Equal((WebSocketCloseStatus)4000, close.CloseStatus);
Assert.True(session.ReadOnly);
await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
Assert.Equal(1, host.DisposedAttachments);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAcknowledgement(bool useGrpc)
{
await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await host.StartAsync(timeout.Token);
using var session = host.CreateViewSession(readOnly: false);
using var browser = await host.ConnectBrowserAsync(session, timeout.Token);
var buffer = new byte[64 * 1024];
// A snapshot can exceed one receive buffer. Drain the complete message without
// acknowledging it so the producer disconnect happens while the next frame waits.
WebSocketReceiveResult initial;
do
{
initial = await browser.ReceiveAsync(buffer, timeout.Token);
Assert.Equal(WebSocketMessageType.Binary, initial.MessageType);
}
while (!initial.EndOfMessage);
await host.Presentation.DisposeAsync();
var closed = await ReadCloseAsync(browser, timeout.Token);
Assert.Equal(WebSocketCloseStatus.EndpointUnavailable, closed.CloseStatus);
Assert.False(session.Ended.IsCompleted);
await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token);
await host.WaitForAttachmentsReleasedAsync(timeout.Token);
Assert.Equal(useGrpc ? 1 : 0, host.DisposedAttachments);
}
private static async Task<string?> ReadFirstLogicalLineAsync(WebSocket socket, int requestId, CancellationToken cancellationToken)
{
// Scroll to retained history, rather than only testing the freshly replayed live screen.
await SendAsync(socket, JsonSerializer.Serialize(new { type = "viewport", requestId, delta = -10000 }), cancellationToken);
var frame = await ReadUntilAsync(socket,
frame => frame.GetProperty("history").GetProperty("requestId").GetInt32() == requestId, cancellationToken);
Assert.Equal(0, frame.GetProperty("history").GetProperty("top").GetInt32());
var history = frame.GetProperty("history");
// HWT line selection returns the logical line, including soft-wrapped continuations.
await SendAsync(socket, JsonSerializer.Serialize(new
{
type = "selection",
action = "start",
mode = "line",
requestId = requestId + 1,
column = 0,
generation = history.GetProperty("generation").GetString(),
rowId = history.GetProperty("rowIds")[0].GetString()
}), cancellationToken);
var selection = await ReadUntilAsync(socket,
frame => frame.GetProperty("history").GetProperty("selection").GetProperty("status").GetString() == "valid",
cancellationToken);
var text = selection.GetProperty("history").GetProperty("selection").GetProperty("text").GetString();
await SendAsync(socket, JsonSerializer.Serialize(new { type = "selection", action = "clear", requestId = requestId + 2 }), cancellationToken);
await ReadUntilAsync(socket,
frame => frame.GetProperty("history").GetProperty("selection").GetProperty("status").GetString() != "valid",
cancellationToken);
await SendAsync(socket, JsonSerializer.Serialize(new { type = "viewport", live = true, requestId = requestId + 3 }), cancellationToken);
await ReadUntilAsync(socket,
frame => frame.GetProperty("history").GetProperty("requestId").GetInt32() == requestId + 3, cancellationToken);
return text;
}
private static async Task<WebSocketReceiveResult> ReadCloseAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[64 * 1024];
while (true)
{
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return result;
}
Assert.Equal(WebSocketMessageType.Binary, result.MessageType);
}
}
private static Task SendAsync(WebSocket socket, string message, CancellationToken cancellationToken)
{
return socket.SendAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text, true, cancellationToken);
}
private static async Task<JsonElement> ReadUntilAsync(WebSocket socket, Func<JsonElement, bool> predicate, CancellationToken cancellationToken)
{
var buffer = new byte[64 * 1024];
while (true)
{
using var message = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(buffer, cancellationToken);
Assert.Equal(WebSocketMessageType.Binary, result.MessageType);
message.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
// HWT1: four-byte magic, little-endian JSON byte length, JSON metadata,
// then binary cell/image sections. Inspect only metadata in these transport tests.
var bytes = message.ToArray();
Assert.Equal("HWT1", Encoding.ASCII.GetString(bytes, 0, 4));
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4));
using var document = JsonDocument.Parse(bytes.AsMemory(8, length));
var frame = document.RootElement;
await SendAsync(socket, $$"""{"type":"ack","revision":{{frame.GetProperty("revision").GetUInt32()}}}""", cancellationToken);
if (predicate(frame))
{
return frame.Clone();
}
}
}
}