#nullable enable using System.Collections.Concurrent; using System.Diagnostics; using System.IO.Pipes; using System.Security.AccessControl; using System.Security.Principal; using System.Text; using System.Text.Json; using Newtonsoft.Json.Linq; using XFE.SeAgent.Cli; using XFE.SeAgent.Plugin.Protocol; using XFE.SeAgent.Plugin.Transport; namespace XFE.SeAgent.Tests; internal static class Program { private static readonly string Fixtures = Path.Combine(Path.GetTempPath(), "XfeSeAgentTests", Guid.NewGuid().ToString("N")); private static readonly List Checks = []; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static async Task Main() { Directory.CreateDirectory(Fixtures); try { TestProtocol(); TestEndpoints(); await TestClientBoundariesAsync(); await TestTransportAndCliAsync(); Console.WriteLine($"PASS {Checks.Count} checks; fixtures: {Fixtures}"); File.WriteAllText(Path.Combine(Fixtures, "results.json"), JsonSerializer.Serialize(new { passed = true, checks = Checks }, Json)); return 0; } catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } } private static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("FAIL " + name); Checks.Add(name); Console.WriteLine("PASS " + name); } private static void Throws(Action action, string name) where T : Exception { try { action(); } catch (T) { Check(true, name); return; } throw new InvalidOperationException("FAIL expected " + typeof(T).Name + ": " + name); } private static async Task ThrowsAsync(Func action, string name) where T : Exception { try { await action(); } catch (T) { Check(true, name); return; } throw new InvalidOperationException("FAIL expected " + typeof(T).Name + ": " + name); } private static JsonElement Params(object value) => JsonSerializer.SerializeToElement(value); private static void TestProtocol() { var request = AgentProtocol.ParseRequest("{\"id\":\"test\",\"method\":\"pb.read\",\"params\":{\"entityId\":\"1234567890123456789\"},\"timeoutMs\":30000}"); Check((string)request.Id! == "test" && request.Parameters["entityId"]!.Value() == "1234567890123456789" && request.TimeoutMs == 30000, "protocol retains string block identifiers and timeout"); Check(AgentProtocol.ParseRequest("{\"id\":1,\"method\":\"world.status\"}").TimeoutMs == AgentProtocol.DefaultTimeoutMs, "protocol applies default timeout and empty params"); foreach (string invalid in new[] { "{}", "[]", "{broken", "{\"id\":1,\"id\":2,\"method\":\"x\"}", "{\"id\":1,\"method\":\"bad name\"}", "{\"id\":1,\"method\":\"x\",\"params\":[]}", "{\"id\":1,\"method\":\"x\",\"timeoutMs\":0}", "{\"id\":1,\"method\":\"x\",\"timeoutMs\":30001}", "{\"id\":1,\"method\":\"x\"} {}" }) Throws(() => AgentProtocol.ParseRequest(invalid), "protocol rejects invalid request " + invalid); Throws(() => AgentProtocol.ParseRequest(new string('x', AgentProtocol.MaxRequestBytes + 1)), "protocol request byte limit enforced"); byte[] encoded = AgentProtocol.SerializeResponse(AgentProtocol.Success(new JValue("id"), new JObject { ["text"] = "中文\n换行" })); Check(encoded[^1] == '\n' && encoded.Count(b => b == '\n') == 1 && !encoded.Take(3).SequenceEqual(new byte[] { 239, 187, 191 }), "response is one UTF-8 JSON line without BOM"); Throws(() => AgentProtocol.SerializeResponse(AgentProtocol.Success(new JValue(1), new JObject { ["large"] = new string('x', AgentProtocol.MaxResponseBytes) })), "response serialization is bounded"); var error = AgentProtocol.Error(new JValue(1), new string('c', 100), new string('m', 4000)); Check(error["error"]!["code"]!.Value()!.Length == 64 && error["error"]!["message"]!.Value()!.Length == 2048, "public error fields bounded"); } private static void TestEndpoints() { string root = Path.Combine(Fixtures, "endpoints"); Directory.CreateDirectory(root); string path = Environment.ProcessPath!; var start = DateTimeOffset.UtcNow; var inspector = new FixtureInspector(new Dictionary { [11] = new(path, start), [12] = new(path, start), [13] = new(path, start), [14] = new(path, start) }); void Write(int id, AgentEndpoint endpoint) => File.WriteAllText(Path.Combine(root, id + ".json"), JsonSerializer.Serialize(endpoint, Json)); var valid = new AgentEndpoint("1.0.0", 1, "XFE.SE.Agent.11", 11, start, path, start); Write(11, valid); Write(12, valid with { Pid = 12, PipeName = "XFE.SE.Agent.12", StartTimeUtc = start.AddSeconds(-1) }); Write(13, valid with { Pid = 13, PipeName = "XFE.SE.Agent.13", ExecutablePath = Path.Combine(Fixtures, "different.exe") }); Write(14, valid with { Pid = 14, PipeName = "wrong-pipe" }); Write(15, valid with { Pid = 15, PipeName = "XFE.SE.Agent.15" }); File.WriteAllText(Path.Combine(root, "invalid.json"), "{broken"); var discovered = new EndpointDiscovery(inspector).Discover(root); Check(discovered.Count == 1 && discovered[0] == valid, "discovery rejects stale start times, executable mismatch, pipe mismatch and exited processes"); using var self = Process.GetCurrentProcess(); var actual = new LocalProcessInspector().Inspect(self.Id); Check(actual?.StartTimeUtc.UtcTicks == new DateTimeOffset(self.StartTime.ToUniversalTime()).UtcTicks && actual.ExecutablePath == self.MainModule!.FileName, "real local process inspection verifies start time and executable path"); } private static async Task TestClientBoundariesAsync() { var client = new AgentPipeClient(); await ThrowsAsync(() => client.CallAsync("missing-" + Guid.NewGuid().ToString("N"), "world.status", Params(new { }), 30), "client missing-pipe connect timeout is bounded"); using (var cancel = new CancellationTokenSource(30)) await ThrowsAsync(() => client.CallAsync("missing-" + Guid.NewGuid().ToString("N"), "world.status", Params(new { }), 1000, cancel.Token), "caller cancellation is distinguished from timeout"); await ThrowsAsync(() => client.CallAsync("fixture", "pb.deploy", Params(new { source = new string('x', AgentPipeClient.MaximumRequestBytes) }), 1000), "oversized request rejected before pipe connection"); foreach (var response in new[] { "not json\n", "{\"id\":\"wrong\",\"result\":{}}\n", "{\"id\":\"$ID\",\"result\":{},\"error\":{}}\n", "{\"id\":\"$ID\",\"result\":{}}" }) { string name = "xfe-client-test-" + Guid.NewGuid().ToString("N"); var serve = ServeRawOnceAsync(name, request => Encoding.UTF8.GetBytes(response.Replace("$ID", request.GetProperty("id").GetString()))); await ThrowsAsync(() => client.CallAsync(name, "world.status", Params(new { }), 1000), "client rejects malformed, mismatched or incomplete response: " + response); await serve; } } private static async Task ServeRawOnceAsync(string pipeName, Func reply) { await using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); await pipe.WaitForConnectionAsync(); using var reader = new StreamReader(pipe, new UTF8Encoding(false), false, 1024, leaveOpen: true); using var document = JsonDocument.Parse((await reader.ReadLineAsync())!); await pipe.WriteAsync(reply(document.RootElement)); await pipe.FlushAsync(); } private static async Task TestTransportAndCliAsync() { var executed = new ConcurrentQueue<(string Method, JObject Parameters, int Thread)>(); using var server = new AgentServer((method, parameters) => { executed.Enqueue((method, (JObject)parameters.DeepClone(), Environment.CurrentManagedThreadId)); if (method == "fixture.error") throw new AgentRpcException("fixture_error", "expected game error"); if (method == "fixture.large") return new JObject { ["payload"] = new string('x', AgentProtocol.MaxResponseBytes) }; return new JObject { ["method"] = method, ["parameters"] = parameters.DeepClone() }; }, _ => { }); server.Start(); using var pump = new PumpThread(server); using (var aclClient = new NamedPipeClientStream(".", server.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { await aclClient.ConnectAsync(1000); var security = aclClient.GetAccessControl(); var rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast().ToArray(); using var identity = WindowsIdentity.GetCurrent(); var network = new SecurityIdentifier(WellKnownSidType.NetworkSid, null); Check(security.AreAccessRulesProtected && rules.Where(rule => rule.AccessControlType == AccessControlType.Allow).All(rule => rule.IdentityReference.Equals(identity.User)) && rules.Any(rule => rule.AccessControlType == AccessControlType.Deny && rule.IdentityReference.Equals(network)), "real pipe ACL permits only current user and denies network access"); } var client = new AgentPipeClient(); var held = client.CallAsync(server.PipeName, "world.status", Params(new { }), 2000); await Task.Delay(80); Check(executed.IsEmpty && !held.IsCompleted, "pipe worker queues work without touching game callback before Pump"); pump.Enabled = true; var answer = await held; Check(!answer.IsError && executed.All(item => item.Thread == pump.ThreadId), "only dedicated game-pump thread executes queued operations"); var concurrent = await Task.WhenAll(Enumerable.Range(0, 4).Select(index => client.CallAsync(server.PipeName, "blocks.get", Params(new { entityId = index.ToString() }), 2000))); Check(concurrent.All(reply => !reply.IsError), "four concurrent clients receive independent correlated responses"); var serverError = await client.CallAsync(server.PipeName, "fixture.error", Params(new { }), 2000); Check(serverError.IsError && serverError.Envelope.GetProperty("error").GetProperty("code").GetString() == "fixture_error", "game RPC errors retain structured code and message"); var oversized = await client.CallAsync(server.PipeName, "fixture.large", Params(new { }), 2000); Check(oversized.IsError && oversized.Envelope.GetProperty("error").GetProperty("code").GetString() == "response_too_large", "oversized game result becomes bounded protocol error"); await TestCliAsync(server.PipeName, executed); using (var raw = new NamedPipeClientStream(".", server.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { await raw.ConnectAsync(1000); await raw.WriteAsync(Encoding.UTF8.GetBytes(new string('x', AgentProtocol.MaxRequestBytes + 1) + "\n")); await raw.FlushAsync(); using var reader = new StreamReader(raw, Encoding.UTF8, false, 1024, true); var response = JObject.Parse((await reader.ReadLineAsync())!); Check(response["error"]!["code"]!.Value() == "request_too_large", "actual pipe transport rejects oversized request before execution"); } pump.Enabled = false; await Task.Delay(30); int before = executed.Count; try { var expired = await client.CallAsync(server.PipeName, "pb.deploy", Params(new { }), 50); Check(expired.IsError && expired.Envelope.GetProperty("error").GetProperty("code").GetString() == "timeout", "request timeout propagates while game pump is paused"); } catch (AgentClientException error) when (error.Code == "timeout") { Check(true, "request timeout propagates while game pump is paused"); } await Task.Delay(30); pump.Enabled = true; await Task.Delay(50); Check(executed.Count == before, "expired request cannot execute later after Pump resumes"); pump.Enabled = false; await Task.Delay(20); using (var raw = new NamedPipeClientStream(".", server.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { await raw.ConnectAsync(1000); await raw.WriteAsync(Encoding.UTF8.GetBytes("{\"id\":\"disconnect\",\"method\":\"pb.deploy\",\"params\":{},\"timeoutMs\":1000}\n")); await raw.FlushAsync(); } await Task.Delay(40); pump.Enabled = true; await Task.Delay(40); Check(executed.Count == before, "disconnected queued mutation is discarded before game execution"); pump.Enabled = false; await Task.Delay(20); var stopped = client.CallAsync(server.PipeName, "pb.deploy", Params(new { }), 1000); await Task.Delay(40); server.Dispose(); try { await stopped; } catch (AgentClientException) { } pump.Enabled = true; await Task.Delay(40); Check(executed.Count == before, "disposing server cancels pending mutations before further Pump"); } private static async Task TestCliAsync(string pipe, ConcurrentQueue<(string Method, JObject Parameters, int Thread)> executed) { var cli = new CliApplication(); async Task<(int Exit, JsonElement Json, string Error)> Run(params string[] args) { using var output = new StringWriter(); using var error = new StringWriter(); int code = await cli.RunAsync(args, output, error); using var json = JsonDocument.Parse(output.ToString()); return (code, json.RootElement.Clone(), error.ToString()); } var help = await Run("--help"); Check(help.Exit == 0 && help.Json.GetProperty("commands").GetArrayLength() == 4 && help.Error.Length == 0, "CLI help is one valid JSON document with no stdout diagnostics"); string parameters = Path.Combine(Fixtures, "parameters.json"); File.WriteAllText(parameters, "{\"entityId\":\"1234567890123456789\",\"argument\":\"中文 参数\"}"); var called = await Run("call", "pb.run", "--params", "@" + parameters, "--pipe", pipe); Check(called.Exit == 0 && called.Error.Length == 0 && called.Json.GetProperty("result").GetProperty("parameters").GetProperty("argument").GetString() == "中文 参数", "CLI call reads @JSON file and preserves Unicode parameters"); string script = Path.Combine(Fixtures, "script.cs"); const string source = "// 中文脚本\npublic void Main() { Echo(\"hello\"); }"; File.WriteAllText(script, source); string hash = new('a', 64); var deployed = await Run("deploy", "--block", "1234567890123456789", "--file", script, "--expected-hash", hash, "--pipe", pipe); var deploy = executed.Last(item => item.Method == "pb.deploy"); Check(deployed.Exit == 0 && deploy.Parameters["source"]!.Value() == source && deploy.Parameters["expectedSha256"]!.Value() == hash && deploy.Parameters["entityId"]!.Type == JTokenType.String, "CLI deploy transmits exact source and mandatory optimistic hash with string entity ID"); int count = executed.Count; var refused = await Run("deploy", "--block", "123", "--file", script, "--pipe", pipe); Check(refused.Exit == 2 && refused.Json.TryGetProperty("error", out _) && executed.Count == count, "CLI deploy without expected hash fails before sending mutation"); var failure = await Run("call", "fixture.error", "--pipe", pipe); Check(failure.Exit == 4 && failure.Json.GetProperty("error").GetProperty("code").GetString() == "fixture_error" && failure.Error.Length > 0, "CLI RPC failure remains JSON on stdout, diagnostic on stderr and nonzero exit"); string jsonl = Path.Combine(Fixtures, "telemetry.jsonl"); var watched = await Run("watch", "--block", "123,-456", "--seconds", "0.14", "--interval", "0.05", "--out", jsonl, "--pipe", pipe); string[] lines = File.ReadAllLines(jsonl); Check(watched.Exit == 0 && lines.Length >= 2 && lines.Length == watched.Json.GetProperty("samples").GetInt32(), "CLI watch writes complete JSONL samples and one stdout summary"); using var sample = JsonDocument.Parse(lines[0]); Check(sample.RootElement.GetProperty("response").GetProperty("result").GetProperty("parameters").GetProperty("entityIds").EnumerateArray().Select(value => value.GetString()).SequenceEqual(new[] { "123", "-456" }), "watch preserves positive and negative Int64 block IDs as strings"); var bad = await Run("call", "world.status", "--timeout", "31", "--pipe", pipe); Check(bad.Exit == 2 && bad.Error.Length > 0, "CLI validates request timeout against server maximum"); var conflict = await Run("call", "world.status", "--pipe", pipe, "--pid", "1"); Check(conflict.Exit == 2, "CLI rejects ambiguous simultaneous pipe and PID selectors"); var invalidPid = await Run("discover", "--pid", "invalid", "--endpoint-directory", Path.Combine(Fixtures, "empty")); Check(invalidPid.Exit == 2, "discover validates PID even when no endpoints exist"); var info = new ProcessStartInfo(Path.Combine(AppContext.BaseDirectory, "xfe-se.exe")) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; foreach (string argument in new[] { "call", "world.status", "--pipe", pipe, "--params", "{\"argument\":\"真实 CLI 进程\"}" }) info.ArgumentList.Add(argument); using var child = Process.Start(info)!; var stdout = child.StandardOutput.ReadToEndAsync(); var stderr = child.StandardError.ReadToEndAsync(); await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); using var childJson = JsonDocument.Parse(await stdout); Check(child.ExitCode == 0 && (await stderr).Length == 0 && childJson.RootElement.GetProperty("result").GetProperty("parameters").GetProperty("argument").GetString() == "真实 CLI 进程", "built CLI executable performs real pipe call with pure JSON stdout"); } private sealed class FixtureInspector(Dictionary processes) : IProcessInspector { public ProcessIdentity? Inspect(int pid) => processes.GetValueOrDefault(pid); } private sealed class PumpThread : IDisposable { private readonly Thread thread; private volatile bool enabled, stopped; private Exception? failure; public int ThreadId => thread.ManagedThreadId; public bool Enabled { get => enabled; set => enabled = value; } public PumpThread(AgentServer server) { thread = new Thread(() => { try { while (!stopped) { if (enabled) server.Pump(); Thread.Sleep(1); } } catch (Exception ex) { failure = ex; } }) { IsBackground = true, Name = "owned-game-pump-fixture" }; thread.Start(); } public void Dispose() { stopped = true; thread.Join(2000); if (failure != null) throw new InvalidOperationException("Game pump fixture failed", failure); } } }