mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
398 lines
16 KiB
Python
398 lines
16 KiB
Python
"""Tests for ``parse_wave.main`` orchestrator + tree-walking helpers.
|
|
|
|
Covers:
|
|
- ``walk_and_inflate`` recursive Flow inflation
|
|
- ``inflate_flow_leaf`` cycle + depth-cap behavior
|
|
- ``build_root_children`` bundle → root children + new_refs
|
|
- ``main`` (full path) env-driven pipeline against a tmp WORK_DIR
|
|
- ``main --finalize-cap`` drain pending → unresolved
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import unittest
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from unittest import mock
|
|
|
|
from . import _bootstrap # noqa: F401 — sys.path setup
|
|
|
|
import parse_wave # type: ignore
|
|
|
|
|
|
_FLOW_NS = "http://soap.sforce.com/2006/04/metadata"
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# walk_and_inflate
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class WalkAndInflateTests(unittest.TestCase):
|
|
|
|
def test_no_op_for_non_FLOW_non_GEN_AI_FUNCTION_node(self):
|
|
node = {"kind": "TOPIC", "api_name": "T1", "children": []}
|
|
flow_children: dict = {}
|
|
# Should not raise; should not mutate.
|
|
parse_wave.walk_and_inflate(node, flow_children)
|
|
self.assertEqual(node["children"], [])
|
|
|
|
def test_inflates_flow_leaf_under_gen_ai_function(self):
|
|
# GEN_AI_FUNCTION wraps a FLOW leaf — walker recurses into it.
|
|
node = {
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "GAF1",
|
|
"children": [{"kind": "FLOW", "api_name": "MyFlow", "children": []}],
|
|
}
|
|
flow_children = {"MyFlow": [
|
|
{"kind": "APEX", "element_name": "callA", "api_name": "MyClass"},
|
|
]}
|
|
parse_wave.walk_and_inflate(node, flow_children)
|
|
# FLOW leaf got inflated with the APEX child
|
|
flow_leaf = node["children"][0]
|
|
self.assertEqual(len(flow_leaf["children"]), 1)
|
|
self.assertEqual(flow_leaf["children"][0]["kind"], "APEX")
|
|
|
|
def test_recursively_walks_through_topic_to_inflate_flow(self):
|
|
# Walker should descend through TOPIC/non-special nodes too.
|
|
node = {
|
|
"kind": "BOT_DEFINITION", "api_name": "Agent",
|
|
"children": [{
|
|
"kind": "TOPIC", "api_name": "T1",
|
|
"children": [{
|
|
"kind": "GEN_AI_FUNCTION", "api_name": "GAF1",
|
|
"children": [{"kind": "FLOW", "api_name": "DeepFlow",
|
|
"children": []}],
|
|
}],
|
|
}],
|
|
}
|
|
flow_children = {"DeepFlow": [
|
|
{"kind": "APEX", "element_name": "callA", "api_name": "DeepClass"},
|
|
]}
|
|
parse_wave.walk_and_inflate(node, flow_children)
|
|
# DeepFlow got the APEX child
|
|
deep_flow = node["children"][0]["children"][0]["children"][0]
|
|
self.assertEqual(deep_flow["children"][0]["api_name"], "DeepClass")
|
|
|
|
def test_pending_out_collects_depth_cap_truncations(self):
|
|
# Build a chain longer than MAX_BFS_DEPTH so the cap trips.
|
|
# Generate 25 flows that each subflow into the next.
|
|
flow_children: dict = {}
|
|
for i in range(25):
|
|
next_name = f"F{i+1}" if i < 24 else None
|
|
kids = []
|
|
if next_name:
|
|
kids.append({"kind": "FLOW", "element_name": f"sub_{i}",
|
|
"api_name": next_name})
|
|
flow_children[f"F{i}"] = kids
|
|
node = {"kind": "FLOW", "api_name": "F0", "children": []}
|
|
pending: dict = parse_wave.empty_kind_sets()
|
|
parse_wave.walk_and_inflate(node, flow_children, pending_out=pending)
|
|
# Some subflow ended up in pending due to the depth cap (>= MAX_BFS_DEPTH).
|
|
self.assertTrue(any(pending[k] for k in parse_wave.BFS_KINDS))
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# inflate_flow_leaf — cycle detection
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class InflateFlowLeafCycleTests(unittest.TestCase):
|
|
|
|
def test_self_recursion_marked_as_cycle(self):
|
|
# Flow A subflows back to A itself → cycle.
|
|
flow_children = {"A": [
|
|
{"kind": "FLOW", "element_name": "self", "api_name": "A"},
|
|
]}
|
|
leaf = {"kind": "FLOW", "api_name": "A", "children": []}
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
# The recursive child got annotated with _truncated.
|
|
kid = leaf["children"][0]
|
|
self.assertIn("_truncated", kid)
|
|
self.assertEqual(kid["_truncated"]["reason"],
|
|
parse_wave.TRUNCATION_CYCLE)
|
|
|
|
def test_unknown_flow_no_inflation(self):
|
|
# leaf.api_name not in flow_children → nothing to inflate.
|
|
leaf = {"kind": "FLOW", "api_name": "Missing", "children": []}
|
|
parse_wave.inflate_flow_leaf(leaf, {})
|
|
self.assertEqual(leaf["children"], [])
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# build_root_children
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class BuildRootChildrenTests(unittest.TestCase):
|
|
|
|
def test_topics_become_root_children(self):
|
|
bundle = {
|
|
"topics": [
|
|
{"name": "Greetings", "actions": [
|
|
{"name": "Hello", "invocationTarget": "MyFlow",
|
|
"invocationTargetType": "flow"},
|
|
]},
|
|
],
|
|
}
|
|
visited = parse_wave.empty_kind_sets()
|
|
aux: set = set()
|
|
children, new_refs = parse_wave.build_root_children(bundle, visited, aux)
|
|
self.assertEqual(len(children), 1)
|
|
self.assertEqual(children[0]["kind"], "TOPIC")
|
|
self.assertEqual(children[0]["api_name"], "Greetings")
|
|
# GEN_AI_FUNCTION wrapping the action
|
|
self.assertEqual(children[0]["children"][0]["kind"], "GEN_AI_FUNCTION")
|
|
# FLOW ref harvested into new_refs
|
|
self.assertIn("MyFlow", new_refs["FLOW"])
|
|
# Topic + action recorded in aux_visited
|
|
self.assertIn(("TOPIC", "Greetings"), aux)
|
|
self.assertIn(("GEN_AI_FUNCTION", "Hello"), aux)
|
|
|
|
def test_skips_already_visited_refs(self):
|
|
bundle = {
|
|
"topics": [{"name": "T", "actions": [
|
|
{"name": "A1", "invocationTarget": "FlowA",
|
|
"invocationTargetType": "flow"},
|
|
]}],
|
|
}
|
|
visited = parse_wave.empty_kind_sets()
|
|
visited["FLOW"].add("FlowA")
|
|
aux: set = set()
|
|
_, new_refs = parse_wave.build_root_children(bundle, visited, aux)
|
|
# FlowA already visited → not added to new_refs
|
|
self.assertNotIn("FlowA", new_refs["FLOW"])
|
|
|
|
def test_standard_action_does_not_pollute_new_refs(self):
|
|
# STANDARD_ACTION is declared-only, must NOT
|
|
# land in new_refs (would pollute _pending_fetches).
|
|
bundle = {
|
|
"topics": [{"name": "T", "actions": [
|
|
{"name": "A1", "invocationTarget": "createRecord",
|
|
"invocationTargetType": "standardinvocableaction"},
|
|
]}],
|
|
}
|
|
visited = parse_wave.empty_kind_sets()
|
|
aux: set = set()
|
|
_, new_refs = parse_wave.build_root_children(bundle, visited, aux)
|
|
self.assertNotIn("createRecord", new_refs["STANDARD_ACTION"])
|
|
|
|
def test_empty_bundle_returns_empty_children(self):
|
|
children, new_refs = parse_wave.build_root_children(
|
|
{}, parse_wave.empty_kind_sets(), set(),
|
|
)
|
|
self.assertEqual(children, [])
|
|
for v in new_refs.values():
|
|
self.assertEqual(v, set())
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# main() — happy path + finalize-cap branch
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _setup_workdir(tmp: Path, *, bundle: dict | None = None) -> Path:
|
|
"""Plant a WORK_DIR with _bundle_parsed.json + minimal sf_meta layout."""
|
|
work_dir = tmp / "work"
|
|
work_dir.mkdir()
|
|
bundle = bundle or {
|
|
"plannerName": "MyPlanner",
|
|
"plannerType": "Atlas__Reasoning",
|
|
"generation": "nga",
|
|
"topics": [{"name": "Greetings", "actions": [
|
|
{"name": "SayHi", "invocationTarget": "GreetingsFlow",
|
|
"invocationTargetType": "flow"},
|
|
]}],
|
|
}
|
|
(work_dir / "_bundle_parsed.json").write_text(json.dumps(bundle))
|
|
# Empty sf_meta is fine — harvest_waves returns empty when missing.
|
|
return work_dir
|
|
|
|
|
|
def _run_main(*, work_dir: Path, argv_extra: list[str] | None = None,
|
|
env_extra: dict | None = None) -> int:
|
|
"""Invoke parse_wave.main with controlled env + argv."""
|
|
saved_env = dict(os.environ)
|
|
saved_argv = list(parse_wave.sys.argv)
|
|
try:
|
|
os.environ.update({
|
|
"WORK_DIR": str(work_dir),
|
|
"AGENT_API_NAME": "DemoAgent",
|
|
"AGENT_VERSION": "v3",
|
|
"BOT_ID": "0Xx000000000ABC",
|
|
"BOT_MASTER_LABEL": "Demo Agent",
|
|
"VERSION_AUTO_PICKED": "false",
|
|
})
|
|
if env_extra:
|
|
os.environ.update(env_extra)
|
|
parse_wave.sys.argv = ["parse_wave.py", *(argv_extra or [])]
|
|
return parse_wave.main()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(saved_env)
|
|
parse_wave.sys.argv = saved_argv
|
|
|
|
|
|
class MainHappyPathTests(unittest.TestCase):
|
|
|
|
def test_main_writes_declared_action_tree_json(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
rc = _run_main(work_dir=work_dir)
|
|
self.assertEqual(rc, 0)
|
|
tree_path = work_dir / "declared_action_tree.json"
|
|
self.assertTrue(tree_path.is_file())
|
|
tree = json.loads(tree_path.read_text())
|
|
# Tree has the expected shape
|
|
self.assertEqual(tree["_schema_version"], "3.1")
|
|
self.assertEqual(tree["agent"]["api_name"], "DemoAgent")
|
|
self.assertEqual(tree["root"]["kind"], "BOT_DEFINITION")
|
|
# One topic became a root child
|
|
kinds = [c["kind"] for c in tree["root"]["children"]]
|
|
self.assertIn("TOPIC", kinds)
|
|
|
|
def test_main_records_pending_for_unfetched_flow(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
_run_main(work_dir=work_dir)
|
|
tree = json.loads(
|
|
(work_dir / "declared_action_tree.json").read_text()
|
|
)
|
|
# GreetingsFlow not on disk → in _pending_fetches.FLOW
|
|
self.assertIn("GreetingsFlow", tree["_pending_fetches"]["FLOW"])
|
|
self.assertTrue(tree["_partial"])
|
|
# _partial_reason populated on first run when pending refs exist
|
|
# but no depth cap was tripped.
|
|
self.assertEqual(tree["_partial_reason"], "pending-refs")
|
|
|
|
def test_main_writes_pending_refs_reason_when_initial_reason_is_none(self):
|
|
# init_tree seeds `_partial_reason=None`. The writer's elif branch
|
|
# must populate it with "pending-refs" when any pending bucket is
|
|
# non-empty and the depth cap did not trip — a setdefault against
|
|
# `None` is a no-op, which is the bug this guards.
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
_run_main(work_dir=work_dir)
|
|
tree = json.loads(
|
|
(work_dir / "declared_action_tree.json").read_text()
|
|
)
|
|
# Sanity: pending exists, depth cap was NOT tripped.
|
|
self.assertIn("GreetingsFlow", tree["_pending_fetches"]["FLOW"])
|
|
# The fix: a None-valued reason gets promoted to "pending-refs".
|
|
self.assertEqual(tree["_partial_reason"], "pending-refs")
|
|
|
|
def test_main_preserves_existing_partial_reason_when_pending_refs(self):
|
|
# init_tree primes _partial_reason=None; setdefault used to be a
|
|
# no-op against that None and left the reason blank. Today the
|
|
# writer treats None as "unset" and fills in "pending-refs".
|
|
# When a prior reason exists (e.g. "max-depth-cap" from a previous
|
|
# wave), the writer must NOT overwrite it.
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
# Plant a tree on disk that mimics a previously-suspended run:
|
|
# _partial_reason="max-depth-cap" and empty root children, so
|
|
# main() enters the fresh-tree branch and re-collects bundle
|
|
# refs (GreetingsFlow pending) yet sees the prior reason.
|
|
tree_path = work_dir / "declared_action_tree.json"
|
|
tree_path.write_text(json.dumps({
|
|
"_schema_version": "3.1",
|
|
"agent": {"api_name": "DemoAgent", "version": "v3"},
|
|
"root": {"kind": "BOT_DEFINITION", "api_name": "DemoAgent",
|
|
"children": []},
|
|
"_partial": True,
|
|
"_partial_reason": "max-depth-cap",
|
|
"_pending_fetches": {k: [] for k in parse_wave.BFS_KINDS},
|
|
"_unresolved": [],
|
|
"_visited": [],
|
|
}))
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
_run_main(work_dir=work_dir)
|
|
tree2 = json.loads(tree_path.read_text())
|
|
# GreetingsFlow is pending → any_pending=True → elif branch runs.
|
|
self.assertIn("GreetingsFlow", tree2["_pending_fetches"]["FLOW"])
|
|
# Existing more-specific reason is preserved.
|
|
self.assertEqual(tree2["_partial_reason"], "max-depth-cap")
|
|
|
|
def test_main_returns_one_when_work_dir_env_missing(self):
|
|
saved_env = dict(os.environ)
|
|
try:
|
|
for k in ("WORK_DIR", "AGENT_API_NAME", "AGENT_VERSION"):
|
|
os.environ.pop(k, None)
|
|
with mock.patch.object(parse_wave.sys, "argv", ["parse_wave.py"]):
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
rc = parse_wave.main()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(saved_env)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_main_returns_one_when_bundle_missing(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = tmp / "work"
|
|
work_dir.mkdir()
|
|
# No _bundle_parsed.json
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
rc = _run_main(work_dir=work_dir)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_main_reparses_existing_tree(self):
|
|
# Two-pass: first run creates the tree, second re-uses it.
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
_run_main(work_dir=work_dir)
|
|
# Second invocation should load + augment without crashing
|
|
rc = _run_main(work_dir=work_dir)
|
|
self.assertEqual(rc, 0)
|
|
|
|
|
|
class MainFinalizeCapTests(unittest.TestCase):
|
|
|
|
def test_finalize_cap_drains_pending_into_unresolved(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = _setup_workdir(tmp)
|
|
# First run produces a tree with pending FLOW=GreetingsFlow.
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
_run_main(work_dir=work_dir)
|
|
# --finalize-cap drains pending → unresolved
|
|
rc = _run_main(
|
|
work_dir=work_dir, argv_extra=["--finalize-cap"],
|
|
)
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads(
|
|
(work_dir / "declared_action_tree.json").read_text()
|
|
)
|
|
# All buckets emptied
|
|
for kind in parse_wave.BFS_KINDS:
|
|
self.assertEqual(tree["_pending_fetches"][kind], [])
|
|
# GreetingsFlow now in _unresolved
|
|
api_names = [u["api_name"] for u in tree["_unresolved"]]
|
|
self.assertIn("GreetingsFlow", api_names)
|
|
|
|
def test_finalize_cap_with_no_existing_tree_is_noop(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = tmp / "work"
|
|
work_dir.mkdir()
|
|
with mock.patch.object(parse_wave.sys, "stderr"):
|
|
rc = _run_main(
|
|
work_dir=work_dir, argv_extra=["--finalize-cap"],
|
|
)
|
|
# No tree file → noop, exit 0.
|
|
self.assertEqual(rc, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|