// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dsl import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) // fixturesDir resolves the testdata/ directory for the dsl package. The // production fixtures live at internal/agent/dsl/testdata/ (after the // v1_examples/ flatten). Helpers and tests below walk that directory // directly so a new fixture is picked up automatically. func fixturesDir(t *testing.T) string { t.Helper() return filepath.Join("testdata") } // loadFixture reads a JSON file from internal/agent/dsl/testdata into a // map[string]any. The function t.Skip()s (not t.Fatal) on a missing // file so a test run on a slim checkout still goes green. func loadFixture(t *testing.T, name string) map[string]any { t.Helper() raw, err := os.ReadFile(filepath.Join(fixturesDir(t), name)) if err != nil { t.Skipf("fixture %s not readable: %v", name, err) } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatalf("[%s] parse: %v", name, err) } return m } // TestNormalize_NoopWhenGraphPresent guards against accidentally // clobbering a payload the front-end just saved. Any input with a // non-empty `graph.nodes` must round-trip with `graph` untouched. func TestNormalize_NoopWhenGraphPresent(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "begin", "type": "beginNode"}, }, "edges": []any{}, }, "components": map[string]any{}, } out := NormalizeForCanvas(in) got, _ := out["graph"].(map[string]any) if got == nil { t.Fatal("graph block disappeared") } if nodes, _ := got["nodes"].([]any); len(nodes) != 1 { t.Errorf("graph.nodes length = %d, want 1", len(nodes)) } } // TestNormalize_BuildsGraphFromComponents verifies the // components → graph derivation path. Given a populated `components` // block but no `graph`, the function should produce a deterministic // graph with one node per component and one edge per downstream // declaration. The graph node's `data.form` must always be present // (even when empty) so the front-end's React Flow shape is stable. func TestNormalize_BuildsGraphFromComponents(t *testing.T) { in := map[string]any{ "components": map[string]any{ "begin": map[string]any{ "obj": map[string]any{"component_name": "Begin", "params": map[string]any{}}, "downstream": []any{"llm:0"}, }, "llm:0": map[string]any{ "obj": map[string]any{"component_name": "LLM", "params": map[string]any{"k": "v"}}, "downstream": []any{}, }, }, } out := NormalizeForCanvas(in) graph, _ := out["graph"].(map[string]any) if graph == nil { t.Fatal("graph not derived from components") } nodes, _ := graph["nodes"].([]any) if len(nodes) != 2 { t.Fatalf("graph.nodes length = %d, want 2", len(nodes)) } edges, _ := graph["edges"].([]any) if len(edges) != 1 { t.Fatalf("graph.edges length = %d, want 1", len(edges)) } // Each derived node must carry data.form, even when the source // params is empty — this is the front-end's React-Flow invariant. for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil { continue } data, _ := nm["data"].(map[string]any) if data == nil { t.Errorf("node %v missing data block", nm["id"]) continue } if _, ok := data["form"]; !ok { t.Errorf("node %v data.form missing", nm["id"]) } } } // TestNormalize_DeterministicNodeOrder guards against the Go runtime // map iteration lottery producing a different graph layout on // successive normalize passes. The function must sort the component // ids before iterating so the layout (x = 50 + i*350) is a stable // function of the input dsl. func TestNormalize_DeterministicNodeOrder(t *testing.T) { comps := map[string]any{} for _, k := range []string{"z", "a", "m", "b"} { comps[k] = map[string]any{ "obj": map[string]any{"component_name": "Begin", "params": map[string]any{}}, "downstream": []any{}, } } first := NormalizeForCanvas(map[string]any{"components": comps}) second := NormalizeForCanvas(map[string]any{"components": comps}) idsFirst := nodeIDs(t, first) idsSecond := nodeIDs(t, second) if !equalStringSlices(idsFirst, idsSecond) { t.Errorf("non-deterministic order: first=%v second=%v", idsFirst, idsSecond) } // Sorted order expected. want := []string{"a", "b", "m", "z"} if !equalStringSlices(idsFirst, want) { t.Errorf("node order = %v, want %v", idsFirst, want) } } // TestNormalize_HandleIdsEnforced ensures source/target handle ids // match the front-end's React Flow convention (source=start, // target=end) regardless of which value the writer used. Agent/tool // handles (non-"end"/"start" ids) are left alone. func TestNormalize_HandleIdsEnforced(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "a", "type": "beginNode"}, map[string]any{"id": "b", "type": "messageNode"}, }, "edges": []any{ // Inverted: source uses "end" instead of "start". map[string]any{ "id": "e1", "source": "a", "target": "b", "sourceHandle": "end", "targetHandle": "start", }, // Agent/tool handle: must NOT be touched. map[string]any{ "id": "e2", "source": "a", "target": "b", "sourceHandle": "tool-1", "targetHandle": "tool-1", }, }, }, "components": map[string]any{}, } out := NormalizeForCanvas(in) edges, _ := out["graph"].(map[string]any)["edges"].([]any) if len(edges) != 2 { t.Fatalf("edges length = %d, want 2", len(edges)) } e1, _ := edges[0].(map[string]any) if e1["sourceHandle"] != "start" { t.Errorf("e1 sourceHandle = %v, want start", e1["sourceHandle"]) } if e1["targetHandle"] != "end" { t.Errorf("e1 targetHandle = %v, want end", e1["targetHandle"]) } e2, _ := edges[1].(map[string]any) if e2["sourceHandle"] != "tool-1" { t.Errorf("e2 sourceHandle = %v, want tool-1 (preserved)", e2["sourceHandle"]) } if e2["targetHandle"] != "tool-1" { t.Errorf("e2 targetHandle = %v, want tool-1 (preserved)", e2["targetHandle"]) } } // TestNormalizeForRun_FoldsLoopAndIteration is the runtime // compatibility step: a dsl carrying the legacy Loop+LoopItem or // Iteration+IterationItem node pair must be folded into a single // Loop/Parallel node, with the child node removed and its downstream // merged into the parent. Iteration parents are also renamed to // "Parallel" so downstream compile/expand paths only see modern names. // // Two cases are exercised end-to-end: // // 1. Loop:abc + LoopItem:def + Body:1 → Loop:abc + Body:1 // (child dropped; Body:1 appended to parent.downstream) // 2. Iteration:abc + IterationItem:def + Body:1 → Parallel:abc + Body:1 // (child dropped; parent renamed to Parallel; Body:1 appended) func TestNormalizeForRun_FoldsLoopAndIteration(t *testing.T) { t.Run("LoopPlusLoopItem", func(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "Loop:abc", "type": "loopNode"}, map[string]any{"id": "LoopItem:def", "type": "loopStartNode", "parentId": "Loop:abc"}, map[string]any{"id": "Body:1", "type": "messageNode"}, }, "edges": []any{}, }, "components": map[string]any{ "Loop:abc": map[string]any{ "obj": map[string]any{"component_name": "Loop", "params": map[string]any{"k": "v"}}, "downstream": []any{"LoopItem:def"}, }, "LoopItem:def": map[string]any{ "obj": map[string]any{"component_name": "LoopItem", "params": map[string]any{}}, "downstream": []any{"Body:1"}, }, "Body:1": map[string]any{ "obj": map[string]any{"component_name": "Message", "params": map[string]any{}}, "downstream": []any{}, }, }, } out := NormalizeForRun(in) comps, _ := out["components"].(map[string]any) if _, dropped := comps["LoopItem:def"]; dropped { t.Error("LoopItem:def should be folded away") } parent, _ := comps["Loop:abc"].(map[string]any) if parent == nil { t.Fatal("Loop:abc missing after fold") } ds, _ := parent["downstream"].([]any) gotDS := stringSliceAny(ds) if !equalStringSlices(gotDS, []string{"Body:1"}) { t.Errorf("parent.downstream = %v, want [Body:1]", gotDS) } }) t.Run("IterationPlusIterationItem", func(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "Iteration:abc", "type": "iterationNode"}, map[string]any{"id": "IterationItem:def", "type": "iterationStartNode", "parentId": "Iteration:abc"}, map[string]any{"id": "Body:1", "type": "messageNode"}, }, "edges": []any{}, }, "components": map[string]any{ "Iteration:abc": map[string]any{ "obj": map[string]any{"component_name": "Iteration", "params": map[string]any{"items_ref": "x"}}, "downstream": []any{"IterationItem:def"}, }, "IterationItem:def": map[string]any{ "obj": map[string]any{"component_name": "IterationItem", "params": map[string]any{}}, "downstream": []any{"Body:1"}, }, "Body:1": map[string]any{ "obj": map[string]any{"component_name": "Message", "params": map[string]any{}}, "downstream": []any{}, }, }, } out := NormalizeForRun(in) comps, _ := out["components"].(map[string]any) if _, dropped := comps["IterationItem:def"]; dropped == false { // has anything, we want it dropped if comps["IterationItem:def"] != nil { t.Error("IterationItem:def should be folded away") } } parent, _ := comps["Iteration:abc"].(map[string]any) if parent == nil { t.Fatal("Iteration:abc missing after fold") } // Renamed to "Parallel". if obj, _ := parent["obj"].(map[string]any); obj != nil { if obj["component_name"] != "Parallel" { t.Errorf("parent.obj.component_name = %v, want Parallel", obj["component_name"]) } } ds, _ := parent["downstream"].([]any) gotDS := stringSliceAny(ds) if !equalStringSlices(gotDS, []string{"Body:1"}) { t.Errorf("parent.downstream = %v, want [Body:1]", gotDS) } // Graph node label also renamed so the front-end's // componentNameToNodeTypeMap lookup ("Parallel" → // "parallelNode") succeeds. graph, _ := out["graph"].(map[string]any) if graph != nil { nodes, _ := graph["nodes"].([]any) for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil { continue } if nm["id"] != "Iteration:abc" { continue } if data, _ := nm["data"].(map[string]any); data != nil { if data["label"] != "Parallel" { t.Errorf("graph node data.label = %v, want Parallel", data["label"]) } } if nm["type"] != "parallelNode" { t.Errorf("graph node type = %v, want parallelNode", nm["type"]) } } } }) } func TestNormalizeForRun_RewritesLegacyIterationAliases(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "Iteration:abc", "type": "iterationNode"}, map[string]any{"id": "IterationItem:def", "type": "iterationStartNode", "parentId": "Iteration:abc"}, map[string]any{"id": "Body:1", "type": "messageNode"}, }, "edges": []any{}, }, "components": map[string]any{ "Iteration:abc": map[string]any{ "obj": map[string]any{ "component_name": "Iteration", "params": map[string]any{ "items_ref": "sys.arr", "outputs": map[string]any{ "lines": map[string]any{ "ref": "StringTransform:FmtItem@result", "type": "Array", }, }, }, }, "downstream": []any{"IterationItem:def", "Done:1"}, }, "IterationItem:def": map[string]any{ "obj": map[string]any{"component_name": "IterationItem", "params": map[string]any{}}, "downstream": []any{"Body:1"}, }, "Body:1": map[string]any{ "obj": map[string]any{ "component_name": "Message", "params": map[string]any{ "content": []any{ "{IterationItem:def@index}: {IterationItem:def@item}", }, }, }, "downstream": []any{}, }, "Done:1": map[string]any{ "obj": map[string]any{ "component_name": "Message", "params": map[string]any{ "content": []any{ "done {Iteration:abc@lines}", }, }, }, "downstream": []any{}, }, }, } out := NormalizeForRun(in) comps, _ := out["components"].(map[string]any) body, _ := comps["Body:1"].(map[string]any) if body == nil { t.Fatal("Body:1 missing after normalize") } obj, _ := body["obj"].(map[string]any) params, _ := obj["params"].(map[string]any) content, _ := params["content"].([]any) if len(content) != 1 { t.Fatalf("content len = %d, want 1", len(content)) } got, _ := content[0].(string) want := "{index}: {item}" if got != want { t.Fatalf("legacy iteration aliases: got %q, want %q", got, want) } } // TestNormalizeForCanvas_PreservesIterationCanvasShape pins the // front-end protocol boundary: canvas-facing normalization may repair // graph structure, but it must not rename Iteration to Parallel or // emit the internal parallelNode type. func TestNormalizeForCanvas_PreservesIterationCanvasShape(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{ "id": "Iteration:abc", "type": "iterationNode", "data": map[string]any{"label": "Iteration", "name": "Iteration"}, }, map[string]any{ "id": "IterationItem:def", "type": "iterationStartNode", "parentId": "Iteration:abc", "data": map[string]any{"label": "IterationItem", "name": "IterationItem"}, }, }, "edges": []any{}, }, "components": map[string]any{ "Iteration:abc": map[string]any{ "obj": map[string]any{"component_name": "Iteration", "params": map[string]any{"items_ref": "x"}}, "downstream": []any{"IterationItem:def"}, }, "IterationItem:def": map[string]any{ "obj": map[string]any{"component_name": "IterationItem", "params": map[string]any{}}, "downstream": []any{}, }, }, } out := NormalizeForCanvas(in) comps, _ := out["components"].(map[string]any) parent, _ := comps["Iteration:abc"].(map[string]any) if parent == nil { t.Fatal("Iteration:abc missing after canvas normalize") } if obj, _ := parent["obj"].(map[string]any); obj != nil { if obj["component_name"] != "Iteration" { t.Errorf("canvas normalize renamed component_name = %v, want Iteration", obj["component_name"]) } } if _, ok := comps["IterationItem:def"]; !ok { t.Error("canvas normalize folded away IterationItem:def; want preserved") } graph, _ := out["graph"].(map[string]any) nodes, _ := graph["nodes"].([]any) for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil || nm["id"] != "Iteration:abc" { continue } if nm["type"] != "iterationNode" { t.Errorf("canvas normalize graph node type = %v, want iterationNode", nm["type"]) } if data, _ := nm["data"].(map[string]any); data != nil { if data["label"] != "Iteration" { t.Errorf("canvas normalize graph label = %v, want Iteration", data["label"]) } } } } // TestNormalizeForCanvas_RepairsLeakedParallelShape verifies that a // historically polluted stored DSL is repaired on read before it goes // back to the front-end: internal Parallel / parallelNode must be // mapped back to Iteration / iterationNode without mutating the input. func TestNormalizeForCanvas_RepairsLeakedParallelShape(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{ "id": "Iteration:abc", "type": "parallelNode", "data": map[string]any{"label": "Parallel", "name": "Parallel"}, }, }, "edges": []any{}, }, "components": map[string]any{ "Iteration:abc": map[string]any{ "obj": map[string]any{ "component_name": "Parallel", "params": map[string]any{}, }, "downstream": []any{}, "upstream": []any{}, }, }, } out := NormalizeForCanvas(in) comps, _ := out["components"].(map[string]any) parent, _ := comps["Iteration:abc"].(map[string]any) obj, _ := parent["obj"].(map[string]any) if obj["component_name"] != "Iteration" { t.Errorf("component_name = %v, want Iteration", obj["component_name"]) } graph, _ := out["graph"].(map[string]any) nodes, _ := graph["nodes"].([]any) node, _ := nodes[0].(map[string]any) if node["type"] != "iterationNode" { t.Errorf("graph node type = %v, want iterationNode", node["type"]) } data, _ := node["data"].(map[string]any) if data["label"] != "Iteration" { t.Errorf("graph node label = %v, want Iteration", data["label"]) } if data["name"] != "Iteration" { t.Errorf("graph node name = %v, want Iteration", data["name"]) } origNode := in["graph"].(map[string]any)["nodes"].([]any)[0].(map[string]any) if origNode["type"] != "parallelNode" { t.Errorf("input node type mutated to %v", origNode["type"]) } } // TestNormalize_DoesNotMutateInput pins the documented // "never mutates its input" contract. The original DSL map's // graph.edges[*].sourceHandle / targetHandle, components // entries, and components[*].obj.component_name must all be // unchanged after NormalizeForCanvas returns. func TestNormalize_DoesNotMutateInput(t *testing.T) { in := map[string]any{ "graph": map[string]any{ "nodes": []any{ map[string]any{"id": "begin", "type": "beginNode"}, }, "edges": []any{ map[string]any{ "id": "e1", "source": "begin", "target": "begin", "sourceHandle": "end", // inverted — to be rewritten "targetHandle": "start", }, }, }, "components": map[string]any{ "Iteration:abc": map[string]any{ "obj": map[string]any{ "component_name": "Iteration", // to be renamed to Parallel "params": map[string]any{"items_ref": "x"}, }, "downstream": []any{"IterationItem:def"}, }, "IterationItem:def": map[string]any{ "obj": map[string]any{ "component_name": "IterationItem", "params": map[string]any{}, }, "downstream": []any{"Body:1"}, }, }, } // Snapshot the original before NormalizeForCanvas runs. origEdge := in["graph"].(map[string]any)["edges"].([]any)[0].(map[string]any) origSourceHandle := origEdge["sourceHandle"] origTargetHandle := origEdge["targetHandle"] origIterObj := in["components"].(map[string]any)["Iteration:abc"].(map[string]any)["obj"].(map[string]any) origIterName := origIterObj["component_name"] iterItemKey := "IterationItem:def" hadIterItem := false if _, ok := in["components"].(map[string]any)[iterItemKey]; ok { hadIterItem = true } // Run the normalizer. _ = NormalizeForRun(in) // (1) graph.edges[*].sourceHandle / targetHandle must NOT be // rewritten in the original input. if got := origEdge["sourceHandle"]; got != origSourceHandle { t.Errorf("input edge sourceHandle mutated: %v -> %v", origSourceHandle, got) } if got := origEdge["targetHandle"]; got != origTargetHandle { t.Errorf("input edge targetHandle mutated: %v -> %v", origTargetHandle, got) } // (2) components[*].obj.component_name must NOT be renamed // in the original input. if got := origIterObj["component_name"]; got != origIterName { t.Errorf("input components[Itr].obj.component_name mutated: %v -> %v", origIterName, got) } // (3) The legacy child component must still be present in // the original components map (fold deletes from the COPY). if !hadIterItem { t.Errorf("input was missing IterationItem:def before normalize — fixture bug") } if _, ok := in["components"].(map[string]any)[iterItemKey]; !ok { t.Errorf("input components[IterationItem:def] was deleted by fold — input was mutated") } } // TestNormalize_FixtureSmoke walks the on-disk testdata/ directory // and runs every fixture through NormalizeForCanvas. The dsl_examples e2e // suite enumerates the same fixture names; keeping the two lists in // sync is the responsibility of internal/agent/canvas/dsl_examples_test.go // — the comment in dsl_examples_test.go is the single source of truth. func TestNormalize_FixtureSmoke(t *testing.T) { dir := fixturesDir(t) entries, err := os.ReadDir(dir) if err != nil { t.Skipf("testdata/ not readable: %v", err) } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } name := e.Name() t.Run(name, func(t *testing.T) { raw := loadFixture(t, name) out := NormalizeForRun(raw) if out == nil { t.Fatalf("[%s] normalize returned nil", name) } // Must have either graph or components at minimum. _, hasGraph := out["graph"].(map[string]any) comps, hasComps := out["components"].(map[string]any) if !hasGraph && !hasComps { t.Errorf("[%s] normalize produced neither graph nor components", name) } // If components survived on the runtime-normalized view, no // Iteration / LoopItem / IterationItem may linger — those are folded. if hasComps { for id, raw := range comps { comp, _ := raw.(map[string]any) if comp == nil { continue } if obj, _ := comp["obj"].(map[string]any); obj != nil { switch obj["component_name"] { case "LoopItem", "IterationItem": t.Errorf("[%s] component %q still has legacy name %q", name, id, obj["component_name"]) case "Iteration": t.Errorf("[%s] component %q still has pre-rename name %q", name, id, obj["component_name"]) } } } } }) } } // ----- helpers ----- func nodeIDs(t *testing.T, dsl map[string]any) []string { t.Helper() graph, _ := dsl["graph"].(map[string]any) if graph == nil { return nil } nodes, _ := graph["nodes"].([]any) ids := make([]string, 0, len(nodes)) for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil { continue } if id, _ := nm["id"].(string); id != "" { ids = append(ids, id) } } return ids } func stringSliceAny(v []any) []string { out := make([]string, 0, len(v)) for _, x := range v { if s, ok := x.(string); ok { out = append(out, s) } } return out } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }