using System.Diagnostics;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Space;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.Space.Contracts;
using XFEWorkflow.Space.Server;
namespace XFEWorkflow.Test;
[TestFixture]
public class SpaceProtocolTests
{
[Test]
public async Task FrameworkClientAndConsoleCompletePublicationRoundTrip()
{
await using var workspace = new TestWorkspace();
var serverRoot = Path.Combine(workspace.Paths.Root, "Server");
var store = new SpaceStore(serverRoot); await store.RegisterAsync("administrator", "fixture-admin-password", "管理员", true);
var url = "http://localhost:" + TestSupport.Port() + "/space/v1/";
var info = new ProcessStartInfo("dotnet") { UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, RedirectStandardOutput = true, RedirectStandardError = true };
info.ArgumentList.Add(TestSupport.FindOutput("XFEWorkflow.Space.Server", "XFEWorkflow.Space.Server.dll"));
info.ArgumentList.Add("--data=" + serverRoot); info.ArgumentList.Add("--url=" + new Uri(url).GetLeftPart(UriPartial.Authority) + "/");
using var process = Process.Start(info)!; var logs = new System.Text.StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (logs) logs.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (logs) logs.AppendLine(e.Data); };
process.BeginOutputReadLine(); process.BeginErrorReadLine();
try
{
workspace.State.SpaceAddress = url; var client = new SpaceClient(workspace.State, new CredentialVault(workspace.State));
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
while (true)
{
try { await client.RequestAsync("health", ct: deadline.Token); break; }
catch (Exception) when (!deadline.IsCancellationRequested) { await Task.Delay(100, deadline.Token); }
}
await client.RequestAsync("register", new() { ["account"] = "author", ["password"] = "fixture-author-password", ["nickname"] = "作者" }, deadline.Token);
var changes = 0; client.UserChanged += () => changes++;
await client.LoginAsync("author", "fixture-author-password", deadline.Token); Assert.Equal(SpaceRole.Author, client.User!.Role); Assert.Equal(1, changes);
await client.UpdateProfileAsync("真实昵称", "https://example.com/avatar.png", deadline.Token);
Assert.Equal("真实昵称", client.User!.NickName); Assert.Equal("https://example.com/avatar.png", client.User.AvatarUrl); Assert.Equal(2, changes);
var restoredClient = new SpaceClient(workspace.State, new CredentialVault(workspace.State));
Assert.Equal("真实昵称", restoredClient.User!.NickName);
await restoredClient.RefreshProfileAsync(deadline.Token); Assert.Equal(client.User, restoredClient.User);
var package = TestSupport.Package(workspace.Paths.Temp, null, ("payload.bin", Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(2 * PluginPackage.ChunkBytes))));
Assert.True(new FileInfo(package).Length > PluginPackage.ChunkBytes);
var publication = await client.UploadAsync(package, ct: deadline.Token); Assert.Equal(PublicationStatus.Pending, publication.Status);
Func<Task> denied = () => client.RequestAsync("reviews/list", ct: deadline.Token); var rejection = await Assert.ThrowsAsync<InvalidOperationException>(denied); Assert.Contains("403", rejection.Message);
await client.LoginAsync("administrator", "fixture-admin-password", deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = publication.Id, ["decision"] = "publish" }, deadline.Token);
var catalog = (await client.RequestAsync("plugins/search", ct: deadline.Token)).Deserialize<CatalogPage>(WorkflowJson.Options)!; Assert.Equal(1, catalog.Total);
var manager = new Infrastructure.Plugins.PluginManager(workspace.Paths, workspace.State);
var installed = await client.DownloadAsync(catalog.Items[0], workspace.Paths, manager, ct: deadline.Token);
Assert.Equal(publication.Manifest.Id, installed.Manifest.Id);
var ai = await client.UploadAsync(TestSupport.Package(workspace.Paths.Temp, new Plugins.Standard.AiPlugin().Manifest), ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = ai.Id, ["decision"] = "publish" }, deadline.Token);
var withDependency = new Plugins.Standard.FilePlugin().Manifest;
withDependency.Dependencies = [new() { Id = ai.Manifest.Id, ExactVersion = ai.Manifest.Version }];
var dependent = await client.UploadAsync(TestSupport.Package(workspace.Paths.Temp, withDependency), ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = dependent.Id, ["decision"] = "publish" }, deadline.Token);
await client.DownloadAsync(dependent, workspace.Paths, manager, ct: deadline.Token);
Assert.True(manager.Installed.Any(x => x.Manifest.Id == "xfe.ai" && x.Enabled)); Assert.Equal(3, manager.Installed.Count);
var graph = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.log", Parameters = new() { ["message"] = "中文与表情😀原样保留" } });
graph.Name = "Space 工作流端到端😀"; graph.Enabled = true; graph.PluginVersions = new() { [withDependency.Id] = withDependency.Version };
var shared = await client.UploadWorkflowAsync(graph, "1.0.0", ct: deadline.Token); Assert.Equal(PublicationStatus.Pending, shared.Status);
Assert.Equal(graph.Id, (await client.PreviewWorkflowAsync(shared, deadline.Token)).Definition.Id);
var previewDenied = await Assert.ThrowsAsync<InvalidOperationException>(() => restoredClient.PreviewWorkflowAsync(shared, deadline.Token)); Assert.Contains("403", previewDenied.Message);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = shared.Id, ["decision"] = "publish" }, deadline.Token);
var workflowCatalog = (await client.RequestAsync("workflows/search", new() { ["query"] = "端到端" }, deadline.Token)).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(1, workflowCatalog.Total);
await using var recipient = new TestWorkspace(); var recipientPlugins = new Infrastructure.Plugins.PluginManager(recipient.Paths, recipient.State); var workflows = new WorkflowRepository(recipient.Paths);
var preview = await restoredClient.PreviewWorkflowAsync(workflowCatalog.Items.Single(), deadline.Token);
Assert.Equal(graph.Id, preview.Definition.Id); Assert.Equal(withDependency.Id, preview.Plugins.Single().Id); Assert.Equal(withDependency.Version, preview.Plugins.Single().Version);
Assert.Equal(0, recipientPlugins.Installed.Count); Assert.Equal(0, (await workflows.ListAsync()).Count); Assert.False(Directory.EnumerateFiles(recipient.Paths.Temp).Any());
var received = await restoredClient.DownloadWorkflowAsync(workflowCatalog.Items.Single(), recipient.Paths, recipientPlugins, workflows, ct: deadline.Token);
Assert.False(received.Id == graph.Id); Assert.False(received.Enabled == true); Assert.Equal(graph.Graph.Nodes[1].Parameters.String("message"), received.Graph.Nodes[1].Parameters.String("message"));
Assert.Equal(2, recipientPlugins.Installed.Count); Assert.True(recipientPlugins.Installed.All(p => p.Enabled));
Assert.Equal(withDependency.Version, recipientPlugins.Installed.Single(p => p.Manifest.Id == withDependency.Id).Manifest.Version);
var another = await restoredClient.DownloadWorkflowAsync(workflowCatalog.Items.Single(), recipient.Paths, recipientPlugins, workflows, ct: deadline.Token); Assert.False(another.Id == received.Id);
Assert.Equal(2, (await workflows.ListAsync()).Count);
var forged = WorkflowJson.Clone(workflowCatalog.Items.Single()); forged.Workflow.Name = "内容不一致";
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.DownloadWorkflowAsync(forged, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
forged = WorkflowJson.Clone(workflowCatalog.Items.Single()); forged.Sha256 = new string('0', 64);
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.PreviewWorkflowAsync(forged, deadline.Token));
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.DownloadWorkflowAsync(forged, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
Assert.Equal(2, (await workflows.ListAsync()).Count);
var code = WorkflowTemplates.CreateCode(); code.Name = "纯代码工作流😀"; var codePackage = Path.Combine(workspace.Paths.Temp, "code.xfeworkflow"); WorkflowPackage.Create(code, codePackage);
var codePublication = await client.UploadWorkflowAsync(codePackage, "1.0.0", ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = codePublication.Id, ["decision"] = "publish" }, deadline.Token);
codePublication.Status = PublicationStatus.Published;
Assert.Equal(code.Code, (await restoredClient.PreviewWorkflowAsync(codePublication, deadline.Token)).Definition.Code);
var receivedCode = await restoredClient.DownloadWorkflowAsync(codePublication, recipient.Paths, recipientPlugins, workflows, false, ct: deadline.Token);
Assert.Equal(WorkflowKind.CSharp, receivedCode.Kind); Assert.Equal(code.Code, receivedCode.Code); Assert.False(receivedCode.Enabled == true);
var missing = WorkflowJson.Clone(graph); missing.Id = Guid.NewGuid().ToString("N"); missing.PluginVersions = new() { ["missing.workflow.plugin"] = "1.0.0" };
var missingPublication = await client.UploadWorkflowAsync(missing, "1.0.0", ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = missingPublication.Id, ["decision"] = "publish" }, deadline.Token);
await Assert.ThrowsAsync<InvalidOperationException>(() => restoredClient.DownloadWorkflowAsync(missingPublication, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
Assert.Equal(3, (await workflows.ListAsync()).Count);
var inspectionCopy = await restoredClient.DownloadWorkflowAsync(missingPublication, recipient.Paths, recipientPlugins, workflows, false, ct: deadline.Token);
Assert.False(inspectionCopy.Enabled == true); Assert.Equal("1.0.0", inspectionCopy.PluginVersions["missing.workflow.plugin"]);
Assert.False(Directory.EnumerateFiles(recipient.Paths.Temp).Any());
await client.LogoutAsync(); Assert.Null(client.User); Assert.Equal(4, changes);
}
catch (Exception ex) { throw new Exception(ex.ToString() + "\n服务端日志:\n" + System.Text.RegularExpressions.Regex.Replace(logs.ToString(), "[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", ""), ex); }
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
}
using System.Diagnostics;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Space;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.Space.Contracts;
using XFEWorkflow.Space.Server;
namespace XFEWorkflow.Test;
[TestFixture]
public class SpaceProtocolTests
{
[Test]
public async Task FrameworkClientAndConsoleCompletePublicationRoundTrip()
{
await using var workspace = new TestWorkspace();
var serverRoot = Path.Combine(workspace.Paths.Root, "Server");
var store = new SpaceStore(serverRoot); await store.RegisterAsync("administrator", "fixture-admin-password", "管理员", true);
var url = "http://localhost:" + TestSupport.Port() + "/space/v1/";
var info = new ProcessStartInfo("dotnet") { UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, RedirectStandardOutput = true, RedirectStandardError = true };
info.ArgumentList.Add(TestSupport.FindOutput("XFEWorkflow.Space.Server", "XFEWorkflow.Space.Server.dll"));
info.ArgumentList.Add("--data=" + serverRoot); info.ArgumentList.Add("--url=" + new Uri(url).GetLeftPart(UriPartial.Authority) + "/");
using var process = Process.Start(info)!; var logs = new System.Text.StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (logs) logs.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (logs) logs.AppendLine(e.Data); };
process.BeginOutputReadLine(); process.BeginErrorReadLine();
try
{
workspace.State.SpaceAddress = url; var client = new SpaceClient(workspace.State, new CredentialVault(workspace.State));
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
while (true)
{
try { await client.RequestAsync("health", ct: deadline.Token); break; }
catch (Exception) when (!deadline.IsCancellationRequested) { await Task.Delay(100, deadline.Token); }
}
await client.RequestAsync("register", new() { ["account"] = "author", ["password"] = "fixture-author-password", ["nickname"] = "作者" }, deadline.Token);
var changes = 0; client.UserChanged += () => changes++;
await client.LoginAsync("author", "fixture-author-password", deadline.Token); Assert.Equal(SpaceRole.Author, client.User!.Role); Assert.Equal(1, changes);
await client.UpdateProfileAsync("真实昵称", "https://example.com/avatar.png", deadline.Token);
Assert.Equal("真实昵称", client.User!.NickName); Assert.Equal("https://example.com/avatar.png", client.User.AvatarUrl); Assert.Equal(2, changes);
var restoredClient = new SpaceClient(workspace.State, new CredentialVault(workspace.State));
Assert.Equal("真实昵称", restoredClient.User!.NickName);
await restoredClient.RefreshProfileAsync(deadline.Token); Assert.Equal(client.User, restoredClient.User);
var package = TestSupport.Package(workspace.Paths.Temp, null, ("payload.bin", Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(2 * PluginPackage.ChunkBytes))));
Assert.True(new FileInfo(package).Length > PluginPackage.ChunkBytes);
var publication = await client.UploadAsync(package, ct: deadline.Token); Assert.Equal(PublicationStatus.Pending, publication.Status);
Func<Task> denied = () => client.RequestAsync("reviews/list", ct: deadline.Token); var rejection = await Assert.ThrowsAsync<InvalidOperationException>(denied); Assert.Contains("403", rejection.Message);
await client.LoginAsync("administrator", "fixture-admin-password", deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = publication.Id, ["decision"] = "publish" }, deadline.Token);
var catalog = (await client.RequestAsync("plugins/search", ct: deadline.Token)).Deserialize<CatalogPage>(WorkflowJson.Options)!; Assert.Equal(1, catalog.Total);
var manager = new Infrastructure.Plugins.PluginManager(workspace.Paths, workspace.State);
var installed = await client.DownloadAsync(catalog.Items[0], workspace.Paths, manager, ct: deadline.Token);
Assert.Equal(publication.Manifest.Id, installed.Manifest.Id);
var ai = await client.UploadAsync(TestSupport.Package(workspace.Paths.Temp, new Plugins.Standard.AiPlugin().Manifest), ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = ai.Id, ["decision"] = "publish" }, deadline.Token);
var withDependency = new Plugins.Standard.FilePlugin().Manifest;
withDependency.Dependencies = [new() { Id = ai.Manifest.Id, ExactVersion = ai.Manifest.Version }];
var dependent = await client.UploadAsync(TestSupport.Package(workspace.Paths.Temp, withDependency), ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["id"] = dependent.Id, ["decision"] = "publish" }, deadline.Token);
await client.DownloadAsync(dependent, workspace.Paths, manager, ct: deadline.Token);
Assert.True(manager.Installed.Any(x => x.Manifest.Id == "xfe.ai" && x.Enabled)); Assert.Equal(3, manager.Installed.Count);
var graph = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.log", Parameters = new() { ["message"] = "中文与表情😀原样保留" } });
graph.Name = "Space 工作流端到端😀"; graph.Enabled = true; graph.PluginVersions = new() { [withDependency.Id] = withDependency.Version };
var shared = await client.UploadWorkflowAsync(graph, "1.0.0", ct: deadline.Token); Assert.Equal(PublicationStatus.Pending, shared.Status);
Assert.Equal(graph.Id, (await client.PreviewWorkflowAsync(shared, deadline.Token)).Definition.Id);
var previewDenied = await Assert.ThrowsAsync<InvalidOperationException>(() => restoredClient.PreviewWorkflowAsync(shared, deadline.Token)); Assert.Contains("403", previewDenied.Message);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = shared.Id, ["decision"] = "publish" }, deadline.Token);
var workflowCatalog = (await client.RequestAsync("workflows/search", new() { ["query"] = "端到端" }, deadline.Token)).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(1, workflowCatalog.Total);
await using var recipient = new TestWorkspace(); var recipientPlugins = new Infrastructure.Plugins.PluginManager(recipient.Paths, recipient.State); var workflows = new WorkflowRepository(recipient.Paths);
var preview = await restoredClient.PreviewWorkflowAsync(workflowCatalog.Items.Single(), deadline.Token);
Assert.Equal(graph.Id, preview.Definition.Id); Assert.Equal(withDependency.Id, preview.Plugins.Single().Id); Assert.Equal(withDependency.Version, preview.Plugins.Single().Version);
Assert.Equal(0, recipientPlugins.Installed.Count); Assert.Equal(0, (await workflows.ListAsync()).Count); Assert.False(Directory.EnumerateFiles(recipient.Paths.Temp).Any());
var received = await restoredClient.DownloadWorkflowAsync(workflowCatalog.Items.Single(), recipient.Paths, recipientPlugins, workflows, ct: deadline.Token);
Assert.False(received.Id == graph.Id); Assert.False(received.Enabled == true); Assert.Equal(graph.Graph.Nodes[1].Parameters.String("message"), received.Graph.Nodes[1].Parameters.String("message"));
Assert.Equal(2, recipientPlugins.Installed.Count); Assert.True(recipientPlugins.Installed.All(p => p.Enabled));
Assert.Equal(withDependency.Version, recipientPlugins.Installed.Single(p => p.Manifest.Id == withDependency.Id).Manifest.Version);
var another = await restoredClient.DownloadWorkflowAsync(workflowCatalog.Items.Single(), recipient.Paths, recipientPlugins, workflows, ct: deadline.Token); Assert.False(another.Id == received.Id);
Assert.Equal(2, (await workflows.ListAsync()).Count);
var forged = WorkflowJson.Clone(workflowCatalog.Items.Single()); forged.Workflow.Name = "内容不一致";
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.DownloadWorkflowAsync(forged, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
forged = WorkflowJson.Clone(workflowCatalog.Items.Single()); forged.Sha256 = new string('0', 64);
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.PreviewWorkflowAsync(forged, deadline.Token));
await Assert.ThrowsAsync<InvalidDataException>(() => restoredClient.DownloadWorkflowAsync(forged, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
Assert.Equal(2, (await workflows.ListAsync()).Count);
var code = WorkflowTemplates.CreateCode(); code.Name = "纯代码工作流😀"; var codePackage = Path.Combine(workspace.Paths.Temp, "code.xfeworkflow"); WorkflowPackage.Create(code, codePackage);
var codePublication = await client.UploadWorkflowAsync(codePackage, "1.0.0", ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = codePublication.Id, ["decision"] = "publish" }, deadline.Token);
codePublication.Status = PublicationStatus.Published;
Assert.Equal(code.Code, (await restoredClient.PreviewWorkflowAsync(codePublication, deadline.Token)).Definition.Code);
var receivedCode = await restoredClient.DownloadWorkflowAsync(codePublication, recipient.Paths, recipientPlugins, workflows, false, ct: deadline.Token);
Assert.Equal(WorkflowKind.CSharp, receivedCode.Kind); Assert.Equal(code.Code, receivedCode.Code); Assert.False(receivedCode.Enabled == true);
var missing = WorkflowJson.Clone(graph); missing.Id = Guid.NewGuid().ToString("N"); missing.PluginVersions = new() { ["missing.workflow.plugin"] = "1.0.0" };
var missingPublication = await client.UploadWorkflowAsync(missing, "1.0.0", ct: deadline.Token);
await client.RequestAsync("reviews/decide", new() { ["kind"] = "workflow", ["id"] = missingPublication.Id, ["decision"] = "publish" }, deadline.Token);
await Assert.ThrowsAsync<InvalidOperationException>(() => restoredClient.DownloadWorkflowAsync(missingPublication, recipient.Paths, recipientPlugins, workflows, ct: deadline.Token));
Assert.Equal(3, (await workflows.ListAsync()).Count);
var inspectionCopy = await restoredClient.DownloadWorkflowAsync(missingPublication, recipient.Paths, recipientPlugins, workflows, false, ct: deadline.Token);
Assert.False(inspectionCopy.Enabled == true); Assert.Equal("1.0.0", inspectionCopy.PluginVersions["missing.workflow.plugin"]);
Assert.False(Directory.EnumerateFiles(recipient.Paths.Temp).Any());
await client.LogoutAsync(); Assert.Null(client.User); Assert.Equal(4, changes);
}
catch (Exception ex) { throw new Exception(ex.ToString() + "\n服务端日志:\n" + System.Text.RegularExpressions.Regex.Replace(logs.ToString(), "[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", ""), ex); }
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
}