using w4c_workflows.Models; using w4c_workflows.Services; using w4c_workflows.Services.Nodes; 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())); private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::workflows::{Path}::{id}"); [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 _)); } // ------------------------------------------------------- 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); } }