XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json.Nodes;
using XFEExtension.NetCore.XFEConsole;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Execution;
using XFEWorkflow.Infrastructure.Plugins;
using XFEWorkflow.Infrastructure.Storage;

namespace XFEWorkflow.Test;

[TestFixture]
public class WorkbenchTests
{
    private static RunCoordinator Coordinator(TestWorkspace workspace, SecretRedactor? redactor = null) => new(workspace.Paths,
        new PluginManager(workspace.Paths, workspace.State), new CredentialVault(workspace.State), redactor ?? new(), TestSupport.FindOutput("XFEWorkflow.Runner", "XFEWorkflow.Runner.exe"));

    [Test]
    public void TriggerBindingsPreserveDefaultsAndCopyNestedEventValues()
    {
        var workflow = TestSupport.Graph(); workflow.InputDefaults = new() { ["default"] = "keep", ["value"] = "old" };
        var trigger = new TriggerDefinition { Kind = TriggerKind.WorkflowCompleted, InputBindings = new()
        {
            ["value"] = new JsonObject { ["$event"] = "outputs.result" },
            ["nested"] = new JsonArray(new JsonObject { ["$event"] = "outputs.result" }, "constant"),
            ["missing"] = new JsonObject { ["$event"] = "unknown" }
        } };
        var payload = new JsonObject { ["outputs"] = new JsonObject { ["result"] = "联动输出" }, ["eventValue"] = 42 };
        var input = TriggerInputs.Build(workflow, trigger, payload, DateTimeOffset.UtcNow);
        Assert.Equal("keep", input.String("default")); Assert.Equal("联动输出", input.String("value")); Assert.Equal(42, input.Integer("eventValue"));
        Assert.Equal("联动输出", input["nested"]![0]!.GetValue<string>()); Assert.True(input["missing"] is null);
        Assert.Equal(trigger.Id, input["trigger"]!["id"]!.GetValue<string>());
        payload["outputs"]!["result"] = "changed";
        Assert.Equal("联动输出", input["trigger"]!["data"]!["outputs"]!["result"]!.GetValue<string>());
        Assert.Equal("old", workflow.InputDefaults.String("value"));
    }

    [Test]
    public async Task RunnerMergesExplicitInputsWithWorkflowDefaultsAndPersistsInputs()
    {
        await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
        var workflow = TestSupport.Graph(); workflow.InputDefaults = new() { ["default"] = "keep", ["value"] = "old" };
        workflow.Graph.Nodes[^1].Parameters["result"] = new JsonObject { ["$input"] = "default" };
        var run = await coordinator.RunAsync(workflow, inputs: new() { ["value"] = "new" });
        Assert.Equal(RunStatus.Succeeded, run.Status, run.Error); Assert.Equal("keep", run.Outputs.String("result"));
        Assert.Equal("new", run.Inputs.String("value")); Assert.Equal(workflow.Id, run.WorkflowChain.Single());
        var saved = (await coordinator.HistoryAsync()).Single(); Assert.Equal("keep", saved.Inputs.String("default")); Assert.Equal(workflow.Graph.Nodes[0].Name, saved.NodeNames[workflow.Graph.Nodes[0].Id]);
    }

    [Test]
    public async Task CompletionTriggerMapsOutputsAndBlocksCycles()
    {
        await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
        var source = TestSupport.Graph(); source.Name = "上游"; source.Graph.Nodes[^1].Parameters["result"] = "upstream";
        var target = TestSupport.Graph(); target.Name = "下游"; target.Graph.Nodes[^1].Parameters["result"] = new JsonObject { ["$input"] = "message" };
        target.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = source.Id,
            InputBindings = new() { ["message"] = new JsonObject { ["$event"] = "outputs.result" } } });
        source.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = target.Id });
        var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(source); await repository.SaveAsync(target);
        await using var scheduler = new WorkflowScheduler(repository, coordinator);
        var finished = new TaskCompletionSource<RunRecord>(TaskCreationOptions.RunContinuationsAsynchronously);
        var cycle = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        scheduler.Error += message => { if (message.Contains("环路")) cycle.TrySetResult(message); };
        coordinator.RunChanged += run => { if (run.WorkflowId == target.Id && run.Status == RunStatus.Succeeded) finished.TrySetResult(run); };
        await scheduler.StartAsync(); var first = await coordinator.RunAsync(source); Assert.Equal(RunStatus.Succeeded, first.Status, first.Error);
        var linked = await finished.Task.WaitAsync(TimeSpan.FromSeconds(15)); await cycle.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal("upstream", linked.Outputs.String("result")); Assert.Equal(first.Id, linked.Inputs["trigger"]!["data"]!["runId"]!.GetValue<string>());
        Assert.True(linked.WorkflowChain.SequenceEqual(new[] { source.Id, target.Id }));
        Assert.Equal(2, (await coordinator.HistoryAsync()).Count);
        Assert.True(scheduler.StatusFor(source).Single().Error!.Contains("环路"));
    }

    [Test]
    public async Task CompletionStatusFilterDoesNotRunOnFailure()
    {
        await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
        var source = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.csharp", Code = "throw new InvalidOperationException(\"expected\");" });
        var target = TestSupport.Graph(); target.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = source.Id, CompletionStatus = RunStatus.Succeeded });
        var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(source); await repository.SaveAsync(target);
        await using var scheduler = new WorkflowScheduler(repository, coordinator); await scheduler.StartAsync();
        var record = await coordinator.RunAsync(source); Assert.Equal(RunStatus.Failed, record.Status); Assert.Equal(1, (await coordinator.HistoryAsync()).Count);
    }

    [Test]
    public async Task EditingIntervalResetsNextExecutionButUnchangedRefreshPreservesIt()
    {
        await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
        var workflow = TestSupport.Graph(); workflow.Triggers.Add(new() { Kind = TriggerKind.Interval, Enabled = true, IntervalSeconds = 600 });
        var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(workflow);
        await using var scheduler = new WorkflowScheduler(repository, coordinator); await scheduler.StartAsync();
        var original = scheduler.StatusFor(workflow).Single().NextRun; await scheduler.RefreshAsync(); Assert.Equal(original, scheduler.StatusFor(workflow).Single().NextRun);
        WorkflowTriggerNodes.Enumerate(workflow).Single().IntervalSeconds = 30; await repository.SaveAsync(workflow); await scheduler.RefreshAsync();
        Assert.True(scheduler.StatusFor(workflow).Single().NextRun < original!.Value.AddMinutes(-8));
        await repository.SetActivationAsync(workflow.Id, false); await scheduler.RefreshAsync(); Assert.True(scheduler.StatusFor(workflow).Single().NextRun is null);
    }

    [Test]
    public async Task WorkflowLogsAreXfeEntriesVisibleLiveDurableAndRedacted()
    {
        await using var workspace = new TestWorkspace(); var redactor = new SecretRedactor(); redactor.Add("test-secret-value");
        await using var coordinator = Coordinator(workspace, redactor); var logs = new WorkflowLogService(workspace.Paths, coordinator, redactor); coordinator.RunChanged += logs.Observe;
        var step = new NodeDefinition { CapabilityId = "core.log", Name = "日志节点", Parameters = new() { ["message"] = "日志 test-secret-value" } };
        var workflow = TestSupport.Graph(step, new() { CapabilityId = "core.delay", Parameters = new() { ["milliseconds"] = 800 } });
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        coordinator.RunChanged += run => { if (run.Events.Any(e => e.Message.Contains("日志 "))) started.TrySetResult(); };
        var running = coordinator.RunAsync(workflow); await started.Task.WaitAsync(TimeSpan.FromSeconds(15));
        var live = (await logs.HistoryAsync()).Where(e => e.WorkflowId == workflow.Id).ToArray();
        Assert.True(live.Any(e => e.NodeName == "日志节点" && e.LogText.Contains("日志 "))); Assert.True(live.All(e => e is XFELogEntry && !e.LogText.Contains("test-secret-value")));
        var result = await running; Assert.Equal(RunStatus.Succeeded, result.Status, result.Error);
        await logs.DiagnosticsAsync(workflow, [new("诊断 test-secret-value", step.Id)]);
        var reopened = new WorkflowLogService(workspace.Paths, coordinator, redactor); var history = (await reopened.HistoryAsync()).Where(e => e.WorkflowId == workflow.Id).ToList();
        Assert.True(history.Any(e => e.RunId == result.Id && e.NodeId == step.Id)); Assert.True(history.Any(e => e.Category == "诊断" && e.Diagnostic is not null));
        Assert.True(history.All(e => !e.LogText.Contains("test-secret-value"))); Assert.Equal(history.Count, history.Select(e => e.Id).Distinct().Count());
        coordinator.RunChanged -= logs.Observe;
    }

    [Test]
    public async Task LoginAndSpaceEndpointIsFixedAndCannotBeSetPublicly()
    {
        await using var workspace = new TestWorkspace();
        Assert.Equal("http://localhost:3300/space/v1/", workspace.State.SpaceAddress);
        Assert.False(typeof(ClientState).GetProperty(nameof(ClientState.SpaceAddress))!.SetMethod!.IsPublic);
        Assert.False(typeof(ClientState).GetProperties().Any(x => x.Name.Contains("InstanceSpaceAddress", StringComparison.Ordinal)));
        await workspace.State.SaveAsync();
        var text = await File.ReadAllTextAsync(Path.Combine(workspace.Paths.Root, "ClientState.xpf"));
        Assert.False(text.Contains("spaceAddress", StringComparison.OrdinalIgnoreCase));
    }
}