diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py
index df6e76ef..4093e2f6 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py
@@ -39,16 +39,26 @@ class MissingBpmnFileForTestCaseError(Exception):
@dataclass
class TestCaseResult:
passed: bool
+ bpmn_file: str
test_case_name: str
error: Optional[str] = None
+DEFAULT_NSMAP = {
+ 'bpmn': 'http://www.omg.org/spec/BPMN/20100524/MODEL',
+ 'bpmndi': 'http://www.omg.org/spec/BPMN/20100524/DI',
+ 'dc': 'http://www.omg.org/spec/DD/20100524/DC',
+}
+
+
# input:
# json_file:
# {
# [TEST_CASE_NAME]: {
# "tasks": {
-# [BPMN_TASK_IDENTIIFER]: [DATA]
+# [BPMN_TASK_IDENTIIFER]: {
+# "data": [DATA]
+# }
# },
# "expected_output_json": [DATA]
# }
@@ -62,17 +72,23 @@ class ProcessModelTestRunner:
def __init__(
self,
process_model_directory_path: str,
- instantiate_executer_callback: Callable[[str], Any],
- execute_task_callback: Callable[[Any, Optional[dict]], Any],
- get_next_task_callback: Callable[[Any], Any],
+ process_model_directory_for_test_discovery: Optional[str] = None,
+ instantiate_executer_callback: Optional[Callable[[str], Any]] = None,
+ execute_task_callback: Optional[Callable[[Any, Optional[dict]], Any]] = None,
+ get_next_task_callback: Optional[Callable[[Any], Any]] = None,
) -> None:
self.process_model_directory_path = process_model_directory_path
- self.test_mappings = self._discover_process_model_directories()
+ self.process_model_directory_for_test_discovery = process_model_directory_for_test_discovery or process_model_directory_path
self.instantiate_executer_callback = instantiate_executer_callback
self.execute_task_callback = execute_task_callback
self.get_next_task_callback = get_next_task_callback
self.test_case_results: list[TestCaseResult] = []
+ self.bpmn_processes_to_file_mappings: dict[str, str] = {}
+ self.bpmn_files_to_called_element_mappings: dict[str, list[str]] = {}
+
+ self.test_mappings = self._discover_process_model_test_cases()
+ self._discover_process_model_processes()
def all_test_cases_passed(self) -> bool:
failed_tests = [t for t in self.test_case_results if t.passed is False]
@@ -87,59 +103,161 @@ class ProcessModelTestRunner:
try:
self.run_test_case(bpmn_file, test_case_name, test_case_contents)
except Exception as ex:
- self.test_case_results.append(
- TestCaseResult(
- passed=False,
- test_case_name=test_case_name,
- error=f"Syntax error: {str(ex)}",
- )
- )
+ self._add_test_result(False, bpmn_file, test_case_name, f"Syntax error: {str(ex)}")
def run_test_case(self, bpmn_file: str, test_case_name: str, test_case_contents: dict) -> None:
- bpmn_process_instance = self.instantiate_executer_callback(bpmn_file)
- next_task = self.get_next_task_callback(bpmn_process_instance)
+ bpmn_process_instance = self._instantiate_executer(bpmn_file)
+ next_task = self._get_next_task(bpmn_process_instance)
while next_task is not None:
- test_case_json = None
+ test_case_task_properties = None
if "tasks" in test_case_contents:
if next_task.task_spec.bpmn_id in test_case_contents["tasks"]:
- test_case_json = test_case_contents["tasks"][next_task.task_spec.bpmn_id]
+ test_case_task_properties = test_case_contents["tasks"][next_task.task_spec.bpmn_id]
task_type = next_task.task_spec.__class__.__name__
- if task_type in ["ServiceTask", "UserTask"] and test_case_json is None:
+ if task_type in ["ServiceTask", "UserTask", "CallActivity"] and test_case_task_properties is None:
raise UnrunnableTestCaseError(
f"Cannot run test case '{test_case_name}'. It requires task data for"
f" {next_task.task_spec.bpmn_id} because it is of type '{task_type}'"
)
- self.execute_task_callback(next_task, test_case_json)
- next_task = self.get_next_task_callback(bpmn_process_instance)
+ self._execute_task(next_task, test_case_task_properties)
+ next_task = self._get_next_task(bpmn_process_instance)
test_passed = test_case_contents["expected_output_json"] == bpmn_process_instance.data
- self.test_case_results.append(
- TestCaseResult(
- passed=test_passed,
- test_case_name=test_case_name,
+ error_message = None
+ if test_passed is False:
+ error_message = (
+ f"Expected output did not match actual output:"
+ f"\nexpected: {test_case_contents['expected_output_json']}"
+ f"\nactual: {bpmn_process_instance.data}"
)
- )
+ self._add_test_result(test_passed, bpmn_file, test_case_name, error_message)
- def _discover_process_model_directories(
+ def _discover_process_model_test_cases(
self,
) -> dict[str, str]:
test_mappings = {}
- json_test_file_glob = os.path.join(self.process_model_directory_path, "**", "test_*.json")
+ json_test_file_glob = os.path.join(self.process_model_directory_for_test_discovery, "**", "test_*.json")
for file in glob.glob(json_test_file_glob, recursive=True):
- file_dir = os.path.dirname(file)
- json_file_name = os.path.basename(file)
+ file_norm = os.path.normpath(file)
+ file_dir = os.path.dirname(file_norm)
+ json_file_name = os.path.basename(file_norm)
bpmn_file_name = re.sub(r"^test_(.*)\.json", r"\1.bpmn", json_file_name)
bpmn_file_path = os.path.join(file_dir, bpmn_file_name)
if os.path.isfile(bpmn_file_path):
- test_mappings[file] = bpmn_file_path
+ test_mappings[file_norm] = bpmn_file_path
else:
raise MissingBpmnFileForTestCaseError(
- f"Cannot find a matching bpmn file for test case json file: '{file}'"
+ f"Cannot find a matching bpmn file for test case json file: '{file_norm}'"
)
return test_mappings
+ def _discover_process_model_processes(
+ self,
+ ) -> None:
+ process_model_bpmn_file_glob = os.path.join(self.process_model_directory_path, "**", "*.bpmn")
+
+ for file in glob.glob(process_model_bpmn_file_glob, recursive=True):
+ file_norm = os.path.normpath(file)
+ if file_norm not in self.bpmn_files_to_called_element_mappings:
+ self.bpmn_files_to_called_element_mappings[file_norm] = []
+ with open(file_norm, 'rb') as f:
+ file_contents = f.read()
+ etree_xml_parser = etree.XMLParser(resolve_entities=False)
+ root = etree.fromstring(file_contents, parser=etree_xml_parser)
+ call_activities = root.findall('.//bpmn:callActivity', namespaces=DEFAULT_NSMAP)
+ for call_activity in call_activities:
+ called_element = call_activity.attrib['calledElement']
+ self.bpmn_files_to_called_element_mappings[file_norm].append(called_element)
+ bpmn_process_element = root.find('.//bpmn:process[@isExecutable="true"]', namespaces=DEFAULT_NSMAP)
+ bpmn_process_identifier = bpmn_process_element.attrib['id']
+ self.bpmn_processes_to_file_mappings[bpmn_process_identifier] = file_norm
+
+ def _execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None:
+ if self.execute_task_callback:
+ self.execute_task_callback(spiff_task, test_case_task_properties)
+ self._default_execute_task(spiff_task, test_case_task_properties)
+
+ def _get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]:
+ if self.get_next_task_callback:
+ return self.get_next_task_callback(bpmn_process_instance)
+ return self._default_get_next_task(bpmn_process_instance)
+
+ def _instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow:
+ if self.instantiate_executer_callback:
+ return self.instantiate_executer_callback(bpmn_file)
+ return self._default_instantiate_executer(bpmn_file)
+
+ def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
+ tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual])
+ if len(tasks) > 0:
+ tasks = [tasks[0]]
+
+ return tasks
+
+ def _default_get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]:
+ engine_steps = self._get_ready_engine_steps(bpmn_process_instance)
+ if len(engine_steps) > 0:
+ return engine_steps[0]
+ return None
+
+ def _default_execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None:
+ if spiff_task.task_spec.manual:
+ if test_case_task_properties and 'data' in test_case_task_properties:
+ spiff_task.update_data(test_case_task_properties['data'])
+ spiff_task.complete()
+ else:
+ spiff_task.run()
+
+ def _find_related_bpmn_files(self, bpmn_file: str) -> list[str]:
+ related_bpmn_files = []
+ if bpmn_file in self.bpmn_files_to_called_element_mappings:
+ for bpmn_process_identifier in self.bpmn_files_to_called_element_mappings[bpmn_file]:
+ if bpmn_process_identifier in self.bpmn_processes_to_file_mappings:
+ new_file = self.bpmn_processes_to_file_mappings[bpmn_process_identifier]
+ related_bpmn_files.append(new_file)
+ related_bpmn_files.extend(self._find_related_bpmn_files(new_file))
+ return related_bpmn_files
+
+ def _get_etree_from_bpmn_file(self, bpmn_file: str) -> etree.Element:
+ data = None
+ with open(bpmn_file, "rb") as f_handle:
+ data = f_handle.read()
+ etree_xml_parser = etree.XMLParser(resolve_entities=False)
+ return etree.fromstring(data, parser=etree_xml_parser)
+
+ def _default_instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow:
+ parser = MyCustomParser()
+ bpmn_file_etree = self._get_etree_from_bpmn_file(bpmn_file)
+ parser.add_bpmn_xml(bpmn_file_etree, filename=os.path.basename(bpmn_file))
+ all_related = self._find_related_bpmn_files(bpmn_file)
+ for related_file in all_related:
+ related_file_etree = self._get_etree_from_bpmn_file(related_file)
+ parser.add_bpmn_xml(related_file_etree, filename=os.path.basename(related_file))
+ sub_parsers = list(parser.process_parsers.values())
+ executable_process = None
+ for sub_parser in sub_parsers:
+ if sub_parser.process_executable:
+ executable_process = sub_parser.bpmn_id
+ if executable_process is None:
+ raise BpmnFileMissingExecutableProcessError(
+ f"Executable process cannot be found in {bpmn_file}. Test cannot run."
+ )
+ bpmn_process_spec = parser.get_spec(executable_process)
+ bpmn_process_instance = BpmnWorkflow(bpmn_process_spec)
+ return bpmn_process_instance
+
+ def _add_test_result(self, passed: bool, bpmn_file: str, test_case_name: str, error: Optional[str] = None) -> None:
+ bpmn_file_relative = os.path.relpath(bpmn_file, start=self.process_model_directory_path)
+ test_result = TestCaseResult(
+ passed=passed,
+ bpmn_file=bpmn_file_relative,
+ test_case_name=test_case_name,
+ error=error,
+ )
+ self.test_case_results.append(test_result)
+
class BpmnFileMissingExecutableProcessError(Exception):
pass
@@ -149,9 +267,9 @@ class ProcessModelTestRunnerService:
def __init__(self, process_model_directory_path: str) -> None:
self.process_model_test_runner = ProcessModelTestRunner(
process_model_directory_path,
- instantiate_executer_callback=self._instantiate_executer_callback,
- execute_task_callback=self._execute_task_callback,
- get_next_task_callback=self._get_next_task_callback,
+ # instantiate_executer_callback=self._instantiate_executer_callback,
+ # execute_task_callback=self._execute_task_callback,
+ # get_next_task_callback=self._get_next_task_callback,
)
def run(self) -> None:
@@ -168,7 +286,6 @@ class ProcessModelTestRunnerService:
def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual])
-
if len(tasks) > 0:
tasks = [tasks[0]]
@@ -179,7 +296,8 @@ class ProcessModelTestRunnerService:
data = None
with open(bpmn_file, "rb") as f_handle:
data = f_handle.read()
- bpmn: etree.Element = SpecFileService.get_etree_from_xml_bytes(data)
+ etree_xml_parser = etree.XMLParser(resolve_entities=False)
+ bpmn = etree.fromstring(data, parser=etree_xml_parser)
parser.add_bpmn_xml(bpmn, filename=os.path.basename(bpmn_file))
sub_parsers = list(parser.process_parsers.values())
executable_process = None
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn
new file mode 100644
index 00000000..ab796146
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn
@@ -0,0 +1,40 @@
+
+
+
+
+ Flow_0ext5lt
+
+
+
+ Flow_1hzwssi
+
+
+
+
+ Flow_0ext5lt
+ Flow_1hzwssi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json
new file mode 100644
index 00000000..8f4e4a04
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json
@@ -0,0 +1,9 @@
+{
+ "description": "",
+ "display_name": "Basic Call Activity",
+ "exception_notification_addresses": [],
+ "fault_or_suspend_on_exception": "fault",
+ "files": [],
+ "primary_file_name": "basic_call_activity.bpmn",
+ "primary_process_id": "BasicCallActivityProcess"
+}
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json
new file mode 100644
index 00000000..0a7f7692
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json
@@ -0,0 +1,5 @@
+{
+ "test_case_one": {
+ "expected_output_json": {}
+ }
+}
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn
new file mode 100644
index 00000000..5d0bf395
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn
@@ -0,0 +1,39 @@
+
+
+
+
+ Flow_0gz6i84
+
+
+
+ Flow_0ikklg6
+
+
+
+ Flow_0gz6i84
+ Flow_0ikklg6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json
new file mode 100644
index 00000000..743dd104
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json
@@ -0,0 +1,9 @@
+{
+ "description": "Baisc Manual Task",
+ "display_name": "Baisc Manual Task",
+ "exception_notification_addresses": [],
+ "fault_or_suspend_on_exception": "fault",
+ "files": [],
+ "primary_file_name": "baisc_manual_task.bpmn",
+ "primary_process_id": "BasicManualTaskProcess"
+}
diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json
new file mode 100644
index 00000000..d82f5c7c
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json
@@ -0,0 +1,10 @@
+{
+ "test_case_one": {
+ "tasks": {
+ "manual_task_one": {
+ "data": {}
+ }
+ },
+ "expected_output_json": {}
+ }
+}
diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py
index a993c945..89ed9ec7 100644
--- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py
+++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py
@@ -9,7 +9,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.services.file_system_service import FileSystemService
-from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunnerService
+from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunner, ProcessModelTestRunnerService
class TestProcessModelTestRunnerService(BaseTest):
@@ -23,7 +23,7 @@ class TestProcessModelTestRunnerService(BaseTest):
os.path.join(FileSystemService.root_path(), "basic_script_task")
)
test_runner_service.run()
- assert test_runner_service.process_model_test_runner.all_test_cases_passed()
+ assert test_runner_service.process_model_test_runner.all_test_cases_passed(), test_runner_service.process_model_test_runner.test_case_results
def test_can_test_multiple_process_models(
self,
@@ -35,6 +35,19 @@ class TestProcessModelTestRunnerService(BaseTest):
test_runner_service.run()
assert test_runner_service.process_model_test_runner.all_test_cases_passed() is False
+ def test_can_test_process_model_call_activity(
+ self,
+ app: Flask,
+ with_db_and_bpmn_file_cleanup: None,
+ with_mocked_root_path: Any,
+ ) -> None:
+ test_runner_service = ProcessModelTestRunner(
+ process_model_directory_path=FileSystemService.root_path(),
+ process_model_directory_for_test_discovery=os.path.join(FileSystemService.root_path(), "basic_call_activity")
+ )
+ test_runner_service.run()
+ assert test_runner_service.all_test_cases_passed() is True, test_runner_service.test_case_results
+
@pytest.fixture()
def with_mocked_root_path(self, mocker: MockerFixture) -> None:
path = os.path.join(