2026-09-01 16:37:53 +00:00
|
|
|
using w4c_workflows.Models;
|
|
|
|
|
using w4c_workflows.Services;
|
2026-09-13 08:35:17 +00:00
|
|
|
using w4c_workflows.Services.Nodes;
|
2026-09-01 16:37:53 +00:00
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
|
|
|
|
|
|
public class WorkflowCompilerTests
|
|
|
|
|
{
|
|
|
|
|
private const string Tenant = "t1";
|
|
|
|
|
private const string Path = "workflows/main-task.yaml";
|
|
|
|
|
|
|
|
|
|
private static readonly string ExampleYaml = """
|
|
|
|
|
name: main-task
|
|
|
|
|
description: Root handler consuming order events
|
|
|
|
|
mode: handler
|
|
|
|
|
language: typescript
|
|
|
|
|
target: default
|
|
|
|
|
trigger:
|
|
|
|
|
type: queue
|
|
|
|
|
stream: orders
|
|
|
|
|
entry: { file: index.ts, function: handler }
|
|
|
|
|
env: { FOO: "bar" }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: validate
|
|
|
|
|
parent: root
|
|
|
|
|
next: enrich
|
|
|
|
|
onError: compensate
|
|
|
|
|
language: python
|
|
|
|
|
entry: { file: validate.py }
|
|
|
|
|
- id: enrich
|
|
|
|
|
parent: root
|
|
|
|
|
next: persist
|
|
|
|
|
language: csharp
|
|
|
|
|
entry: { file: Enrich.cs }
|
|
|
|
|
- id: persist
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: persist.sh }
|
|
|
|
|
- id: compensate
|
|
|
|
|
parent: root
|
|
|
|
|
language: python
|
|
|
|
|
entry: { file: compensate.py }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
private static WorkflowCompiler NewCompiler()
|
|
|
|
|
=> new(new WorkflowValidator(new LanguageRegistry()));
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::workflows::{Path}::{id}");
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Compiles_plan_example_with_correct_flow()
|
|
|
|
|
{
|
|
|
|
|
var result = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
var wf = result.Workflow!.Workflow;
|
|
|
|
|
var tasks = result.Workflow!.Tasks;
|
|
|
|
|
|
|
|
|
|
Assert.Equal("main-task", wf.Name);
|
|
|
|
|
Assert.Equal(WorkflowMode.Handler, wf.Mode);
|
|
|
|
|
Assert.Equal("compiled", wf.Status);
|
|
|
|
|
|
|
|
|
|
// 1 root + 4 declared tasks.
|
|
|
|
|
Assert.Equal(5, tasks.Count);
|
|
|
|
|
|
|
|
|
|
var root = tasks.Single(t => t.Order == 0);
|
|
|
|
|
var validate = tasks.Single(t => t.Id == TaskId("validate"));
|
|
|
|
|
var enrich = tasks.Single(t => t.Id == TaskId("enrich"));
|
|
|
|
|
var persist = tasks.Single(t => t.Id == TaskId("persist"));
|
|
|
|
|
var compensate = tasks.Single(t => t.Id == TaskId("compensate"));
|
|
|
|
|
|
|
|
|
|
Assert.Null(root.ParentId);
|
|
|
|
|
Assert.Equal(validate.Id, root.NextId);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(root.Id, validate.ParentId);
|
|
|
|
|
Assert.Equal(enrich.Id, validate.NextId);
|
|
|
|
|
Assert.Equal(compensate.Id, validate.OnErrorId);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(enrich.NextId, persist.Id);
|
|
|
|
|
Assert.Null(persist.NextId); // terminal
|
|
|
|
|
|
|
|
|
|
// Compensation task is organizational (parent: root), not on the chain.
|
|
|
|
|
Assert.Equal(root.Id, compensate.ParentId);
|
|
|
|
|
Assert.Null(compensate.NextId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Compiles_are_idempotent_same_ids_every_time()
|
|
|
|
|
{
|
|
|
|
|
var compiler = NewCompiler();
|
|
|
|
|
var first = compiler.Compile(ExampleYaml, Path, Tenant);
|
|
|
|
|
var second = compiler.Compile(ExampleYaml, Path, Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(first.Workflow!.Workflow.Id, second.Workflow!.Workflow.Id);
|
|
|
|
|
Assert.Equal(
|
|
|
|
|
first.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x),
|
|
|
|
|
second.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Compiles_root_only_workflow()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: ping
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: ping.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, "workflows/ping.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
Assert.Single(result.Workflow!.Tasks);
|
|
|
|
|
Assert.Null(result.Workflow!.Tasks[0].NextId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("", "name is required")]
|
|
|
|
|
[InlineData("mode: function\nlanguage: shell\nentry: { file: a.sh }", "name is required")]
|
|
|
|
|
[InlineData("name: x\nlanguage: shell\nentry: { file: a.sh }", "mode is required")]
|
|
|
|
|
[InlineData("name: x\nmode: function\nentry: { file: a.sh }", "language is required")]
|
|
|
|
|
[InlineData("name: x\nmode: function\nlanguage: cobol\nentry: { file: a.sh }", "language 'cobol' is not supported")]
|
|
|
|
|
public void Rejects_invalid_header(string yaml, string expectedError)
|
|
|
|
|
{
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.False(result.Success);
|
|
|
|
|
Assert.Contains(result.Errors, e => e.Contains(expectedError, StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Rejects_unknown_next_reference()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: x
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: ghost
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.Contains(result.Errors, e => e.Contains("next 'ghost' does not reference", StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Rejects_duplicate_task_ids()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: x
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: a
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.Contains(result.Errors, e => e.Contains("'a' is duplicated", StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Rejects_cycle_in_success_chain()
|
|
|
|
|
{
|
|
|
|
|
// root -> a -> b -> c -> b (b↔c cycle; b also has 2 incoming edges)
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: x
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: b
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
next: c
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
- id: c
|
|
|
|
|
next: b
|
|
|
|
|
entry: { file: c.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.False(result.Success);
|
|
|
|
|
Assert.Contains(result.Errors, e =>
|
|
|
|
|
e.Contains("cycle", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
e.Contains("linear", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Rejects_handler_mode_with_non_queue_trigger()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: x
|
|
|
|
|
mode: handler
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
trigger:
|
|
|
|
|
type: cron
|
|
|
|
|
cron: "0 * * * *"
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.Contains(result.Errors, e => e.Contains("handler mode requires trigger.type = 'queue'", StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Rejects_unreachable_task()
|
|
|
|
|
{
|
|
|
|
|
// a -> b, but d is disconnected (not on the chain, not a compensation target).
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: x
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: b
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
- id: d
|
|
|
|
|
parent: root
|
|
|
|
|
entry: { file: d.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
Assert.Contains(result.Errors, e => e.Contains("unreachable", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Resolves_server_reference_from_header_default_and_task_override()
|
|
|
|
|
{
|
|
|
|
|
// S8: a workflow-level `server` becomes the default for every task; an explicit
|
|
|
|
|
// task-level `server` overrides it. Absent everywhere => null (local subprocess).
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: srv-flow
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
server: srv-default
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: b
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
server: srv-optimized
|
|
|
|
|
next: c
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
- id: c
|
|
|
|
|
entry: { file: c.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
var tasks = result.Workflow!.Tasks;
|
|
|
|
|
|
|
|
|
|
// Root + 3 declared tasks.
|
|
|
|
|
var root = tasks.Single(t => t.Order == 0);
|
|
|
|
|
var a = tasks.Single(t => t.Key == "a");
|
|
|
|
|
var b = tasks.Single(t => t.Key == "b");
|
|
|
|
|
var c = tasks.Single(t => t.Key == "c");
|
|
|
|
|
|
|
|
|
|
Assert.Equal("srv-default", root.Server); // header default applies to root
|
|
|
|
|
Assert.Equal("srv-default", a.Server); // header default when no override
|
|
|
|
|
Assert.Equal("srv-optimized", b.Server); // task-level override wins
|
|
|
|
|
Assert.Equal("srv-default", c.Server); // header default for a chain task
|
|
|
|
|
|
|
|
|
|
// No server anywhere => null keeps the local subprocess default.
|
|
|
|
|
var bare = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
|
|
|
|
Assert.All(bare.Workflow!.Tasks, t => Assert.Null(t.Server));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DurationParser_parses_common_forms()
|
|
|
|
|
{
|
|
|
|
|
Assert.True(DurationParser.TryParse("45s", out var s));
|
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(45), s);
|
|
|
|
|
Assert.True(DurationParser.TryParse("30m", out var m));
|
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(30), m);
|
|
|
|
|
Assert.True(DurationParser.TryParse("2h", out var h));
|
|
|
|
|
Assert.Equal(TimeSpan.FromHours(2), h);
|
|
|
|
|
Assert.True(DurationParser.TryParse("1h30m", out var compound));
|
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(90), compound);
|
|
|
|
|
Assert.False(DurationParser.TryParse("soon", out _));
|
|
|
|
|
}
|
2026-09-13 08:35:17 +00:00
|
|
|
|
|
|
|
|
// ------------------------------------------------------- S1 convergence
|
|
|
|
|
|
|
|
|
|
private const string FunctionScriptYaml = """
|
|
|
|
|
name: legacy-fn
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: b
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
private static WorkflowCompiler NewLoweringCompiler()
|
|
|
|
|
=> new(new WorkflowValidator(new LanguageRegistry()), lowerLegacyScripts: true);
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Lowers_function_script_to_node_mode_when_enabled()
|
|
|
|
|
{
|
|
|
|
|
var result = NewLoweringCompiler().Compile(FunctionScriptYaml, "workflows/legacy-fn.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
var tasks = result.Workflow!.Tasks;
|
|
|
|
|
|
|
|
|
|
// Every step becomes a node step; the synthetic entry plus both tasks exist.
|
|
|
|
|
Assert.All(tasks, t => Assert.Equal("node", t.Language));
|
|
|
|
|
Assert.All(tasks, t => Assert.False(string.IsNullOrWhiteSpace(t.NodeType)));
|
|
|
|
|
Assert.Contains(tasks, t => t.Key == LegacyWorkflowLowerer.EntryStepId);
|
|
|
|
|
Assert.Contains(tasks, t => t.Key == "a");
|
|
|
|
|
Assert.Contains(tasks, t => t.Key == "b");
|
|
|
|
|
|
|
|
|
|
// The `next` chain is lowered into port edges instead of NextId.
|
|
|
|
|
Assert.All(tasks, t => Assert.Null(t.NextId));
|
|
|
|
|
Assert.NotEmpty(result.Workflow.Edges);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Keeps_durable_scripts_on_the_legacy_engine_when_lowering_enabled()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: durable-legacy
|
|
|
|
|
mode: durable
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewLoweringCompiler().Compile(yaml, "workflows/durable-legacy.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
// Durable/handler keep the linear engine: the node kernel does not
|
|
|
|
|
// checkpoint/resume yet, so lowering them would lose semantics.
|
|
|
|
|
Assert.All(result.Workflow!.Tasks, t => Assert.Null(t.NodeType));
|
|
|
|
|
Assert.Empty(result.Workflow.Edges);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Lowering_is_off_by_default()
|
|
|
|
|
{
|
|
|
|
|
var result = NewCompiler().Compile(FunctionScriptYaml, "workflows/legacy-fn.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
Assert.All(result.Workflow!.Tasks, t => Assert.Null(t.NodeType));
|
|
|
|
|
Assert.Empty(result.Workflow.Edges);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Webhook_trigger_populates_the_indexed_webhook_path()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: hook
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
trigger:
|
|
|
|
|
type: webhook
|
|
|
|
|
webhookPath: /h/orders
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, "workflows/hook.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
Assert.Equal("/h/orders", result.Workflow!.Workflow.WebhookPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Node_workflow_webhook_trigger_also_populates_the_column()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: hook-node
|
|
|
|
|
trigger:
|
|
|
|
|
type: webhook
|
|
|
|
|
webhookPath: /h/node-orders
|
|
|
|
|
tasks:
|
|
|
|
|
- id: only
|
|
|
|
|
node: { type: core.noop }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, "workflows/hook-node.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
Assert.Equal("/h/node-orders", result.Workflow!.Workflow.WebhookPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Non_webhook_trigger_leaves_the_webhook_path_null()
|
|
|
|
|
{
|
|
|
|
|
var yaml = """
|
|
|
|
|
name: cron-only
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
trigger:
|
|
|
|
|
type: cron
|
|
|
|
|
cron: "0 * * * *"
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
var result = NewCompiler().Compile(yaml, "workflows/cron-only.yaml", Tenant);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
Assert.Null(result.Workflow!.Workflow.WebhookPath);
|
|
|
|
|
}
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|