Merge nucleic/warm-north-vole-pf37 into dev
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using NucleicProtocol.Interop;
|
||||
|
||||
namespace NucleicApp.Tests;
|
||||
|
||||
internal sealed class FakeNativeProtocol : INativeProtocol
|
||||
{
|
||||
private NativeProtocolCallback? eventCallback;
|
||||
private NativeProtocolCallback? stateCallback;
|
||||
private nint context;
|
||||
|
||||
public string? ConfigurationJson { get; private set; }
|
||||
public string? RendezvousJson { get; private set; }
|
||||
public List<string> Intents { get; } = [];
|
||||
public bool Closed { get; private set; }
|
||||
public string ProjectionResult { get; set; } = "{\"splices\":[],\"version\":1}";
|
||||
|
||||
public nint ClientCreate(string configurationJson)
|
||||
{
|
||||
ConfigurationJson = configurationJson;
|
||||
return 11;
|
||||
}
|
||||
|
||||
public void ClientSetCallbacks(
|
||||
nint handle, NativeProtocolCallback? eventCallback,
|
||||
NativeProtocolCallback? stateCallback, nint context)
|
||||
{
|
||||
this.eventCallback = eventCallback;
|
||||
this.stateCallback = stateCallback;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public int ClientConnectLocal(nint handle, string rendezvousJson)
|
||||
{
|
||||
RendezvousJson = rendezvousJson;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int ClientConnectRemote(nint handle, string endpointJson) => 0;
|
||||
public int ClientPair(nint handle, string pairingCode) => 0;
|
||||
public int ClientSendIntent(nint handle, string clientMessageJson)
|
||||
{
|
||||
Intents.Add(clientMessageJson);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void ClientClose(nint handle) => Closed = true;
|
||||
public nint ProjectionCreate() => 22;
|
||||
public nint ProjectionApply(nint projection, string hostMessageJson) =>
|
||||
Marshal.StringToCoTaskMemUTF8(ProjectionResult);
|
||||
public void ProjectionFree(nint projection) { }
|
||||
public string CopyAndFreeString(nint value)
|
||||
{
|
||||
try { return Marshal.PtrToStringUTF8(value)!; }
|
||||
finally { Marshal.FreeCoTaskMem(value); }
|
||||
}
|
||||
|
||||
public void EmitEvent(string json) => Emit(eventCallback, json);
|
||||
public void EmitState(string json) => Emit(stateCallback, json);
|
||||
|
||||
private void Emit(NativeProtocolCallback? callback, string json)
|
||||
{
|
||||
var pointer = Marshal.StringToCoTaskMemUTF8(json);
|
||||
try { callback?.Invoke(context, pointer); }
|
||||
finally { Marshal.FreeCoTaskMem(pointer); }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeProtocolClient : IProtocolClient
|
||||
{
|
||||
public event Action<string>? MessageJsonReceived;
|
||||
public event Action<string>? StateJsonReceived;
|
||||
public HostdRendezvous? Rendezvous { get; private set; }
|
||||
public List<string> Intents { get; } = [];
|
||||
public Queue<ProtocolResult> ConnectLocalResults { get; } = [];
|
||||
public int ConnectLocalCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public ProtocolResult ConnectLocal(HostdRendezvous rendezvous)
|
||||
{
|
||||
ConnectLocalCount++;
|
||||
Rendezvous = rendezvous;
|
||||
return ConnectLocalResults.TryDequeue(out var result) ? result : ProtocolResult.Ok;
|
||||
}
|
||||
public ProtocolResult ConnectRemote(string endpointJson) => ProtocolResult.Ok;
|
||||
public ProtocolResult Pair(string pairingCode) => ProtocolResult.Ok;
|
||||
public ProtocolResult SendIntent(string clientMessageJson)
|
||||
{
|
||||
Intents.Add(clientMessageJson);
|
||||
return ProtocolResult.Ok;
|
||||
}
|
||||
public void Dispose() => Disposed = true;
|
||||
public void EmitMessage(string json) => MessageJsonReceived?.Invoke(json);
|
||||
public void EmitState(string json) => StateJsonReceived?.Invoke(json);
|
||||
}
|
||||
|
||||
internal sealed class FakeProtocolClientFactory(FakeProtocolClient client) : IProtocolClientFactory
|
||||
{
|
||||
public ProtocolClientConfiguration? Configuration { get; private set; }
|
||||
public IProtocolClient Create(ProtocolClientConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration;
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TestRepository
|
||||
{
|
||||
public static string Root
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, "fixtures", "protocol-abi")))
|
||||
return current.FullName;
|
||||
current = current.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("could not locate repository fixtures");
|
||||
}
|
||||
}
|
||||
|
||||
public static string Fixture(string side, string name) => File.ReadAllText(
|
||||
Path.Combine(Root, "fixtures", "protocol-abi", "v1", side, name + ".json"));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../NucleicApp/NucleicApp.csproj" />
|
||||
<ProjectReference Include="../NucleicProtocol.Interop/NucleicProtocol.Interop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using NucleicProtocol.Interop;
|
||||
|
||||
namespace NucleicApp.Tests;
|
||||
|
||||
public sealed class ProtocolInteropTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryGoldenJsonFixtureHasTheFilenameDiscriminator()
|
||||
{
|
||||
var root = Path.Combine(TestRepository.Root, "fixtures", "protocol-abi", "v1");
|
||||
var clients = Directory.GetFiles(Path.Combine(root, "client"), "*.json");
|
||||
var hosts = Directory.GetFiles(Path.Combine(root, "host"), "*.json");
|
||||
|
||||
Assert.Equal(65, clients.Length);
|
||||
Assert.Equal(52, hosts.Length);
|
||||
foreach (var path in clients.Concat(hosts))
|
||||
{
|
||||
var expected = Path.GetFileNameWithoutExtension(path);
|
||||
var message = ProtocolMessage.Parse(File.ReadAllText(path));
|
||||
Assert.Equal(expected, message.Tag);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagedClientPinsCallbackContextUntilNativeCloseReturns()
|
||||
{
|
||||
var native = new FakeNativeProtocol();
|
||||
var messages = new List<string>();
|
||||
using (var client = new ProtocolClient(new ProtocolClientConfiguration
|
||||
{
|
||||
IdentityDirectory = @"C:\Users\me\AppData\Roaming\Nucleic-local\renderer",
|
||||
Channel = "local",
|
||||
}, native))
|
||||
{
|
||||
client.MessageJsonReceived += messages.Add;
|
||||
native.EmitEvent("{\"t\":\"pong\"}");
|
||||
var result = client.ConnectLocal(ValidRendezvous());
|
||||
Assert.Equal(ProtocolResult.Ok, result);
|
||||
Assert.Contains("\"hostID\":\"host-1\"", native.RendezvousJson);
|
||||
}
|
||||
|
||||
Assert.True(native.Closed);
|
||||
Assert.Single(messages, "{\"t\":\"pong\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectionCopiesThenFreesNativeString()
|
||||
{
|
||||
var native = new FakeNativeProtocol
|
||||
{
|
||||
ProjectionResult = "{\"sessionID\":\"s1\",\"splices\":[],\"version\":1}",
|
||||
};
|
||||
using var projection = new ProtocolProjection(native);
|
||||
var diff = projection.Apply("{\"t\":\"pong\"}");
|
||||
Assert.Equal("s1", diff.Root.GetProperty("sessionID").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntentHelpersMatchFrozenWireShapes()
|
||||
{
|
||||
AssertJsonEqual(TestRepository.Fixture("client", "listSessions"), ClientIntents.ListSessions());
|
||||
AssertJsonEqual(TestRepository.Fixture("client", "unsubscribe"), ClientIntents.Unsubscribe("session-1"));
|
||||
var expectedSend = "{\"input\":{\"parts\":[{\"text\":\"continue\",\"type\":\"text\"}]},\"sessionID\":\"session-1\",\"t\":\"sendInput\"}";
|
||||
AssertJsonEqual(expectedSend, ClientIntents.SendInput("session-1", "continue"));
|
||||
}
|
||||
|
||||
internal static HostdRendezvous ValidRendezvous() => new()
|
||||
{
|
||||
ProcessId = Environment.ProcessId,
|
||||
Host = "127.0.0.1",
|
||||
Port = 51900,
|
||||
LocalPsk = Convert.ToBase64String(new byte[32]),
|
||||
HostId = "host-1",
|
||||
};
|
||||
|
||||
private static void AssertJsonEqual(string expected, string actual)
|
||||
{
|
||||
using var expectedDocument = JsonDocument.Parse(expected);
|
||||
using var actualDocument = JsonDocument.Parse(actual);
|
||||
Assert.True(JsonElement.DeepEquals(expectedDocument.RootElement, actualDocument.RootElement),
|
||||
$"expected {expected}\nactual {actual}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using NucleicApp.Services;
|
||||
using NucleicProtocol.Interop;
|
||||
|
||||
namespace NucleicApp.Tests;
|
||||
|
||||
public sealed class RendererCoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void StoreProjectsSessionsApprovalsAndSettingsFromHostMessagesOnly()
|
||||
{
|
||||
using var store = new RendererStore();
|
||||
store.Apply(TestRepository.Fixture("host", "sessionList"));
|
||||
store.Apply(TestRepository.Fixture("host", "approvalRequested"));
|
||||
store.Apply(TestRepository.Fixture("host", "dashboard"));
|
||||
store.Apply(TestRepository.Fixture("host", "settings"));
|
||||
|
||||
var session = Assert.Single(store.Sessions);
|
||||
Assert.Equal("session-1", session.Id);
|
||||
Assert.Equal("awaitingApproval", session.Status);
|
||||
Assert.Equal(1, session.PendingApprovalCount);
|
||||
Assert.Equal("approval-1", Assert.Single(store.PendingApprovals).Id);
|
||||
Assert.NotNull(store.Dashboard);
|
||||
Assert.True(store.Settings!.Value.GetProperty("resolveApprovalsViaCloud").GetBoolean());
|
||||
|
||||
store.Apply(TestRepository.Fixture("host", "approvalResolved"));
|
||||
Assert.Empty(store.PendingApprovals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostConnectionReadsRendezvousAndRequestsInitialStateOnReady()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory("nucleic-renderer-");
|
||||
try
|
||||
{
|
||||
var rendezvousPath = Path.Combine(directory.FullName, "hostd.json");
|
||||
await File.WriteAllTextAsync(rendezvousPath, JsonSerializer.Serialize(
|
||||
ProtocolInteropTests.ValidRendezvous()));
|
||||
using var store = new RendererStore();
|
||||
var client = new FakeProtocolClient();
|
||||
var factory = new FakeProtocolClientFactory(client);
|
||||
await using var connection = new HostConnection(
|
||||
store, Path.Combine(directory.FullName, "identity"), factory,
|
||||
InlineRendererDispatcher.Instance);
|
||||
|
||||
await connection.StartLocalAsync(rendezvousPath);
|
||||
Assert.Equal("host-1", client.Rendezvous?.HostId);
|
||||
client.EmitState("{\"state\":\"ready\",\"hostID\":\"host-1\",\"welcome\":{}}");
|
||||
Assert.Equal(HostConnectionState.Connected, connection.State);
|
||||
|
||||
var tags = client.Intents.Select(json => ProtocolMessage.Parse(json).Tag).ToArray();
|
||||
Assert.Equal(["listSessions", "listDashboard", "listPeers"], tags);
|
||||
|
||||
client.EmitMessage(TestRepository.Fixture("host", "sessionUpdated"));
|
||||
Assert.Equal("session-1", Assert.Single(store.Sessions).Id);
|
||||
}
|
||||
finally { directory.Delete(recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostConnectionKeepsRetryingAfterSynchronousAdmissionFailures()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory("nucleic-reconnect-");
|
||||
try
|
||||
{
|
||||
var rendezvousPath = Path.Combine(directory.FullName, "hostd.json");
|
||||
await File.WriteAllTextAsync(rendezvousPath, JsonSerializer.Serialize(
|
||||
ProtocolInteropTests.ValidRendezvous()));
|
||||
using var store = new RendererStore();
|
||||
var client = new FakeProtocolClient();
|
||||
client.ConnectLocalResults.Enqueue(ProtocolResult.InvalidState);
|
||||
client.ConnectLocalResults.Enqueue(ProtocolResult.InvalidState);
|
||||
client.ConnectLocalResults.Enqueue(ProtocolResult.Ok);
|
||||
await using var connection = new HostConnection(
|
||||
store, Path.Combine(directory.FullName, "identity"),
|
||||
new FakeProtocolClientFactory(client), InlineRendererDispatcher.Instance);
|
||||
|
||||
await connection.StartLocalAsync(rendezvousPath);
|
||||
await WaitUntilAsync(() => client.ConnectLocalCount == 3, TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.Equal(3, client.ConnectLocalCount);
|
||||
Assert.Equal(HostConnectionState.Reconnecting, connection.State);
|
||||
}
|
||||
finally { directory.Delete(recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LauncherAcceptsOnlyALiveLoopbackRendezvous()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory("nucleic-launcher-");
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var record = ProtocolInteropTests.ValidRendezvous() with
|
||||
{
|
||||
Port = ((IPEndPoint)listener.LocalEndpoint).Port,
|
||||
};
|
||||
var path = Path.Combine(directory.FullName, "hostd.json");
|
||||
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(record));
|
||||
var launcher = new HostdLauncher(new HostdLaunchOptions(
|
||||
Path.Combine(directory.FullName, "must-not-launch.exe"), path));
|
||||
var result = await launcher.EnsureRunningAsync();
|
||||
Assert.Equal(record.Port, result.Port);
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
using var deadline = new CancellationTokenSource(timeout);
|
||||
while (!condition()) await Task.Delay(10, deadline.Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user