Merge pull request #275 from sartography/feature/process_model_unit_tests

Feature/process model unit tests
This commit is contained in:
jasquat 2023-05-23 15:55:47 -04:00 committed by GitHub
commit 2db6b10b7d
54 changed files with 1772 additions and 111 deletions

View File

@ -23,6 +23,11 @@ if [[ -z "${SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then
export SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR
fi
database_host="localhost"
if [[ -n "${SPIFFWORKFLOW_BACKEND_DATABASE_URI:-}" ]]; then
database_host=$(grep -oP "^[^:]+://.*@\K(.+?)[:/]" <<<"$SPIFFWORKFLOW_BACKEND_DATABASE_URI" | sed -E 's/[:\/]$//')
fi
tasks=""
if [[ "${1:-}" == "clean" ]]; then
subcommand="${2:-}"
@ -37,8 +42,8 @@ if [[ "${1:-}" == "clean" ]]; then
if [[ "${SPIFFWORKFLOW_BACKEND_DATABASE_TYPE:-mysql}" != "mysql" ]]; then
rm -f ./src/instance/*.sqlite3
else
mysql -uroot -e "DROP DATABASE IF EXISTS spiffworkflow_backend_local_development"
mysql -uroot -e "DROP DATABASE IF EXISTS spiffworkflow_backend_unit_testing"
mysql -h "$database_host" -uroot -e "DROP DATABASE IF EXISTS spiffworkflow_backend_local_development"
mysql -h "$database_host" -uroot -e "DROP DATABASE IF EXISTS spiffworkflow_backend_unit_testing"
fi
# TODO: check to see if the db already exists and we can connect to it. also actually clean it up.
@ -74,8 +79,8 @@ else
fi
if [[ "${SPIFFWORKFLOW_BACKEND_DATABASE_TYPE:-mysql}" == "mysql" ]]; then
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_local_development"
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_unit_testing"
mysql -h "$database_host" -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_local_development"
mysql -h "$database_host" -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_unit_testing"
fi
for task in $tasks; do
@ -85,7 +90,7 @@ done
SPIFFWORKFLOW_BACKEND_ENV=unit_testing FLASK_APP=src/spiffworkflow_backend poetry run flask db upgrade
if [[ -n "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]] && ! grep -Eq '^(local_development|unit_testing)$' <<< "$SPIFFWORKFLOW_BACKEND_ENV"; then
if [[ "${SPIFFWORKFLOW_BACKEND_DATABASE_TYPE:-mysql}" == "mysql" ]]; then
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_$SPIFFWORKFLOW_BACKEND_ENV"
mysql -h "$database_host" -uroot -e "CREATE DATABASE IF NOT EXISTS spiffworkflow_backend_$SPIFFWORKFLOW_BACKEND_ENV"
fi
FLASK_APP=src/spiffworkflow_backend poetry run flask db upgrade
fi

View File

@ -48,9 +48,8 @@ def with_db_and_bpmn_file_cleanup() -> None:
try:
yield
finally:
process_model_service = ProcessModelService()
if os.path.exists(process_model_service.root_path()):
shutil.rmtree(process_model_service.root_path())
if os.path.exists(ProcessModelService.root_path()):
shutil.rmtree(ProcessModelService.root_path())
@pytest.fixture()

View File

@ -451,6 +451,48 @@ paths:
schema:
$ref: "#/components/schemas/ProcessModel"
/process-model-tests/{modified_process_model_identifier}:
parameters:
- name: modified_process_model_identifier
in: path
required: true
description: The process_model_id, modified to replace slashes (/)
schema:
type: string
- name: test_case_file
in: query
required: false
description: The name of the test case file to run
schema:
type: string
- name: test_case_identifier
in: query
required: false
description: The name of the test case file to run
schema:
type: string
post:
operationId: spiffworkflow_backend.routes.process_models_controller.process_model_test_run
summary: Run a test for a process model
tags:
- Process Model Tests
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
"201":
description: Metadata about the uploaded file, but not the file content.
content:
application/json:
schema:
$ref: "#/components/schemas/File"
/process-models/{modified_process_model_identifier}/files:
parameters:
- name: modified_process_model_identifier

View File

@ -10,7 +10,7 @@ from spiffworkflow_backend.services.logging_service import setup_logger
class ConfigurationError(Exception):
"""ConfigurationError."""
pass
def setup_database_configs(app: Flask) -> None:

View File

@ -53,7 +53,7 @@ def process_group_delete(modified_process_group_id: str) -> flask.wrappers.Respo
process_group_id = _un_modify_modified_process_model_id(modified_process_group_id)
try:
ProcessModelService().process_group_delete(process_group_id)
ProcessModelService.process_group_delete(process_group_id)
except ProcessModelWithInstancesNotDeletableError as exception:
raise ApiError(
error_code="existing_instances",
@ -88,7 +88,7 @@ def process_group_list(
process_group_identifier: Optional[str] = None, page: int = 1, per_page: int = 100
) -> flask.wrappers.Response:
process_groups = ProcessModelService.get_process_groups_for_api(process_group_identifier)
batch = ProcessModelService().get_batch(items=process_groups, page=page, per_page=per_page)
batch = ProcessModelService.get_batch(items=process_groups, page=page, per_page=per_page)
pages = len(process_groups) // per_page
remainder = len(process_groups) % per_page
if remainder > 0:
@ -128,7 +128,7 @@ def process_group_show(
def process_group_move(modified_process_group_identifier: str, new_location: str) -> flask.wrappers.Response:
"""Process_group_move."""
original_process_group_id = _un_modify_modified_process_model_id(modified_process_group_identifier)
new_process_group = ProcessModelService().process_group_move(original_process_group_id, new_location)
new_process_group = ProcessModelService.process_group_move(original_process_group_id, new_location)
_commit_and_push_to_git(
f"User: {g.user.username} moved process group {original_process_group_id} to {new_process_group.id}"
)

View File

@ -30,6 +30,7 @@ from spiffworkflow_backend.routes.process_api_blueprint import _get_process_mode
from spiffworkflow_backend.routes.process_api_blueprint import (
_un_modify_modified_process_model_id,
)
from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.git_service import GitCommandError
from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.git_service import MissingGitConfigsError
@ -43,6 +44,7 @@ from spiffworkflow_backend.services.process_model_service import ProcessModelSer
from spiffworkflow_backend.services.process_model_service import (
ProcessModelWithInstancesNotDeletableError,
)
from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunner
from spiffworkflow_backend.services.spec_file_service import (
ProcessModelFileInvalidError,
)
@ -104,7 +106,7 @@ def process_model_delete(
"""Process_model_delete."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
try:
ProcessModelService().process_model_delete(process_model_identifier)
ProcessModelService.process_model_delete(process_model_identifier)
except ProcessModelWithInstancesNotDeletableError as exception:
raise ApiError(
error_code="existing_instances",
@ -182,7 +184,7 @@ def process_model_show(modified_process_model_identifier: str, include_file_refe
def process_model_move(modified_process_model_identifier: str, new_location: str) -> flask.wrappers.Response:
"""Process_model_move."""
original_process_model_id = _un_modify_modified_process_model_id(modified_process_model_identifier)
new_process_model = ProcessModelService().process_model_move(original_process_model_id, new_location)
new_process_model = ProcessModelService.process_model_move(original_process_model_id, new_location)
_commit_and_push_to_git(
f"User: {g.user.username} moved process model {original_process_model_id} to {new_process_model.id}"
)
@ -219,7 +221,7 @@ def process_model_list(
recursive=recursive,
filter_runnable_by_user=filter_runnable_by_user,
)
process_models_to_return = ProcessModelService().get_batch(process_models, page=page, per_page=per_page)
process_models_to_return = ProcessModelService.get_batch(process_models, page=page, per_page=per_page)
if include_parent_groups:
process_group_cache = IdToProcessGroupMapping({})
@ -314,6 +316,29 @@ def process_model_file_show(modified_process_model_identifier: str, file_name: s
return make_response(jsonify(file), 200)
def process_model_test_run(
modified_process_model_identifier: str,
test_case_file: Optional[str] = None,
test_case_identifier: Optional[str] = None,
) -> flask.wrappers.Response:
process_model_identifier = modified_process_model_identifier.replace(":", "/")
process_model = _get_process_model(process_model_identifier)
process_model_test_runner = ProcessModelTestRunner(
process_model_directory_path=FileSystemService.root_path(),
process_model_directory_for_test_discovery=FileSystemService.full_path_from_id(process_model.id),
test_case_file=test_case_file,
test_case_identifier=test_case_identifier,
)
process_model_test_runner.run()
response_json = {
"all_passed": process_model_test_runner.all_test_cases_passed(),
"passing": process_model_test_runner.passing_tests(),
"failing": process_model_test_runner.failing_tests(),
}
return make_response(jsonify(response_json), 200)
# {
# "natural_language_text": "Create a bug tracker process model \
# with a bug-details form that collects summary, description, and priority"

View File

@ -49,13 +49,12 @@ class FileSystemService:
"""Id_string_to_relative_path."""
return id_string.replace("/", os.sep)
@staticmethod
def process_group_path(name: str) -> str:
"""Category_path."""
@classmethod
def full_path_from_id(cls, id: str) -> str:
return os.path.abspath(
os.path.join(
FileSystemService.root_path(),
FileSystemService.id_string_to_relative_path(name),
cls.root_path(),
cls.id_string_to_relative_path(id),
)
)
@ -65,36 +64,35 @@ class FileSystemService:
return os.path.join(FileSystemService.root_path(), relative_path)
@staticmethod
def process_model_relative_path(spec: ProcessModelInfo) -> str:
def process_model_relative_path(process_model: ProcessModelInfo) -> str:
"""Get the file path to a process model relative to SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR.
If the full path is /path/to/process-group-a/group-b/process-model-a, it will return:
process-group-a/group-b/process-model-a
"""
workflow_path = FileSystemService.workflow_path(spec)
workflow_path = FileSystemService.process_model_full_path(process_model)
return os.path.relpath(workflow_path, start=FileSystemService.root_path())
@staticmethod
def process_group_path_for_spec(spec: ProcessModelInfo) -> str:
"""Category_path_for_spec."""
def process_group_path_for_spec(process_model: ProcessModelInfo) -> str:
# os.path.split apparently returns 2 element tulple like: (first/path, last_item)
process_group_id, _ = os.path.split(spec.id_for_file_path())
return FileSystemService.process_group_path(process_group_id)
process_group_id, _ = os.path.split(process_model.id_for_file_path())
return FileSystemService.full_path_from_id(process_group_id)
@classmethod
def process_model_full_path(cls, process_model: ProcessModelInfo) -> str:
return cls.full_path_from_id(process_model.id)
@staticmethod
def workflow_path(spec: ProcessModelInfo) -> str:
"""Workflow_path."""
process_model_path = os.path.join(FileSystemService.root_path(), spec.id_for_file_path())
return process_model_path
@staticmethod
def full_path_to_process_model_file(spec: ProcessModelInfo) -> str:
def full_path_to_process_model_file(process_model: ProcessModelInfo) -> str:
"""Full_path_to_process_model_file."""
return os.path.join(FileSystemService.workflow_path(spec), spec.primary_file_name) # type: ignore
return os.path.join(
FileSystemService.process_model_full_path(process_model), process_model.primary_file_name # type: ignore
)
def next_display_order(self, spec: ProcessModelInfo) -> int:
def next_display_order(self, process_model: ProcessModelInfo) -> int:
"""Next_display_order."""
path = self.process_group_path_for_spec(spec)
path = self.process_group_path_for_spec(process_model)
if os.path.exists(path):
return len(next(os.walk(path))[1])
else:

View File

@ -420,7 +420,6 @@ class ProcessInstanceProcessor:
)
self.process_instance_model = process_instance_model
self.process_model_service = ProcessModelService()
bpmn_process_spec = None
self.full_bpmn_process_dict: dict = {}
@ -1018,7 +1017,7 @@ class ProcessInstanceProcessor:
ready_or_waiting_tasks = self.get_all_ready_or_waiting_tasks()
process_model_display_name = ""
process_model_info = self.process_model_service.get_process_model(
process_model_info = ProcessModelService.get_process_model(
self.process_instance_model.process_model_identifier
)
if process_model_info is not None:

View File

@ -196,8 +196,7 @@ class ProcessInstanceService:
"""
# navigation = processor.bpmn_process_instance.get_deep_nav_list()
# ProcessInstanceService.update_navigation(navigation, processor)
process_model_service = ProcessModelService()
process_model_service.get_process_model(processor.process_model_identifier)
ProcessModelService.get_process_model(processor.process_model_identifier)
process_instance_api = ProcessInstanceApi(
id=processor.get_process_instance_id(),
status=processor.get_status(),

View File

@ -60,12 +60,7 @@ class ProcessModelService(FileSystemService):
def is_process_group_identifier(cls, process_group_identifier: str) -> bool:
"""Is_process_group_identifier."""
if os.path.exists(FileSystemService.root_path()):
process_group_path = os.path.abspath(
os.path.join(
FileSystemService.root_path(),
FileSystemService.id_string_to_relative_path(process_group_identifier),
)
)
process_group_path = FileSystemService.full_path_from_id(process_group_identifier)
return cls.is_process_group(process_group_path)
return False
@ -82,12 +77,7 @@ class ProcessModelService(FileSystemService):
def is_process_model_identifier(cls, process_model_identifier: str) -> bool:
"""Is_process_model_identifier."""
if os.path.exists(FileSystemService.root_path()):
process_model_path = os.path.abspath(
os.path.join(
FileSystemService.root_path(),
FileSystemService.id_string_to_relative_path(process_model_identifier),
)
)
process_model_path = FileSystemService.full_path_from_id(process_model_identifier)
return cls.is_process_model(process_model_path)
return False
@ -104,7 +94,6 @@ class ProcessModelService(FileSystemService):
page: int = 1,
per_page: int = 10,
) -> list[T]:
"""Get_batch."""
start = (page - 1) * per_page
end = start + per_page
return items[start:end]
@ -139,8 +128,8 @@ class ProcessModelService(FileSystemService):
cls.write_json_file(json_path, json_data)
process_model.id = process_model_id
def process_model_delete(self, process_model_id: str) -> None:
"""Delete Procecss Model."""
@classmethod
def process_model_delete(cls, process_model_id: str) -> None:
instances = ProcessInstanceModel.query.filter(
ProcessInstanceModel.process_model_identifier == process_model_id
).all()
@ -148,19 +137,19 @@ class ProcessModelService(FileSystemService):
raise ProcessModelWithInstancesNotDeletableError(
f"We cannot delete the model `{process_model_id}`, there are existing instances that depend on it."
)
process_model = self.get_process_model(process_model_id)
path = self.workflow_path(process_model)
process_model = cls.get_process_model(process_model_id)
path = cls.process_model_full_path(process_model)
shutil.rmtree(path)
def process_model_move(self, original_process_model_id: str, new_location: str) -> ProcessModelInfo:
"""Process_model_move."""
process_model = self.get_process_model(original_process_model_id)
original_model_path = self.workflow_path(process_model)
@classmethod
def process_model_move(cls, original_process_model_id: str, new_location: str) -> ProcessModelInfo:
process_model = cls.get_process_model(original_process_model_id)
original_model_path = cls.process_model_full_path(process_model)
_, model_id = os.path.split(original_model_path)
new_relative_path = os.path.join(new_location, model_id)
new_model_path = os.path.abspath(os.path.join(FileSystemService.root_path(), new_relative_path))
shutil.move(original_model_path, new_model_path)
new_process_model = self.get_process_model(new_relative_path)
new_process_model = cls.get_process_model(new_relative_path)
return new_process_model
@classmethod
@ -314,12 +303,7 @@ class ProcessModelService(FileSystemService):
def get_process_group(cls, process_group_id: str, find_direct_nested_items: bool = True) -> ProcessGroup:
"""Look for a given process_group, and return it."""
if os.path.exists(FileSystemService.root_path()):
process_group_path = os.path.abspath(
os.path.join(
FileSystemService.root_path(),
FileSystemService.id_string_to_relative_path(process_group_id),
)
)
process_group_path = FileSystemService.full_path_from_id(process_group_id)
if cls.is_process_group(process_group_path):
return cls.find_or_create_process_group(
process_group_path,
@ -330,13 +314,11 @@ class ProcessModelService(FileSystemService):
@classmethod
def add_process_group(cls, process_group: ProcessGroup) -> ProcessGroup:
"""Add_process_group."""
return cls.update_process_group(process_group)
@classmethod
def update_process_group(cls, process_group: ProcessGroup) -> ProcessGroup:
"""Update_process_group."""
cat_path = cls.process_group_path(process_group.id)
cat_path = cls.full_path_from_id(process_group.id)
os.makedirs(cat_path, exist_ok=True)
json_path = os.path.join(cat_path, cls.PROCESS_GROUP_JSON_FILE)
serialized_process_group = process_group.serialized
@ -346,33 +328,33 @@ class ProcessModelService(FileSystemService):
cls.write_json_file(json_path, serialized_process_group)
return process_group
def process_group_move(self, original_process_group_id: str, new_location: str) -> ProcessGroup:
"""Process_group_move."""
original_group_path = self.process_group_path(original_process_group_id)
@classmethod
def process_group_move(cls, original_process_group_id: str, new_location: str) -> ProcessGroup:
original_group_path = cls.full_path_from_id(original_process_group_id)
_, original_group_id = os.path.split(original_group_path)
new_root = os.path.join(FileSystemService.root_path(), new_location)
new_group_path = os.path.abspath(os.path.join(FileSystemService.root_path(), new_root, original_group_id))
destination = shutil.move(original_group_path, new_group_path)
new_process_group = self.get_process_group(destination)
new_process_group = cls.get_process_group(destination)
return new_process_group
def __get_all_nested_models(self, group_path: str) -> list:
"""__get_all_nested_models."""
@classmethod
def __get_all_nested_models(cls, group_path: str) -> list:
all_nested_models = []
for _root, dirs, _files in os.walk(group_path):
for dir in dirs:
model_dir = os.path.join(group_path, dir)
if ProcessModelService.is_process_model(model_dir):
process_model = self.get_process_model(model_dir)
process_model = cls.get_process_model(model_dir)
all_nested_models.append(process_model)
return all_nested_models
def process_group_delete(self, process_group_id: str) -> None:
"""Delete_process_group."""
@classmethod
def process_group_delete(cls, process_group_id: str) -> None:
problem_models = []
path = self.process_group_path(process_group_id)
path = cls.full_path_from_id(process_group_id)
if os.path.exists(path):
nested_models = self.__get_all_nested_models(path)
nested_models = cls.__get_all_nested_models(path)
for process_model in nested_models:
instances = ProcessInstanceModel.query.filter(
ProcessInstanceModel.process_model_identifier == process_model.id
@ -386,15 +368,15 @@ class ProcessModelService(FileSystemService):
f" {problem_models}"
)
shutil.rmtree(path)
self.cleanup_process_group_display_order()
cls._cleanup_process_group_display_order()
def cleanup_process_group_display_order(self) -> List[Any]:
"""Cleanup_process_group_display_order."""
process_groups = self.get_process_groups() # Returns an ordered list
@classmethod
def _cleanup_process_group_display_order(cls) -> List[Any]:
process_groups = cls.get_process_groups() # Returns an ordered list
index = 0
for process_group in process_groups:
process_group.display_order = index
self.update_process_group(process_group)
cls.update_process_group(process_group)
index += 1
return process_groups

View File

@ -0,0 +1,407 @@
import glob
import json
import os
import re
import traceback
from dataclasses import dataclass
from typing import Any
from typing import Callable
from typing import Optional
from typing import Union
from lxml import etree # type: ignore
from SpiffWorkflow.bpmn.exceptions import WorkflowTaskException # type: ignore
from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.task import TaskState
from spiffworkflow_backend.services.custom_parser import MyCustomParser
class UnrunnableTestCaseError(Exception):
pass
class MissingBpmnFileForTestCaseError(Exception):
pass
class NoTestCasesFoundError(Exception):
pass
class MissingInputTaskData(Exception):
pass
@dataclass
class TestCaseErrorDetails:
error_messages: list[str]
task_error_line: Optional[str] = None
task_trace: Optional[list[str]] = None
task_bpmn_identifier: Optional[str] = None
task_bpmn_name: Optional[str] = None
task_line_number: Optional[int] = None
stacktrace: Optional[list[str]] = None
@dataclass
class TestCaseResult:
passed: bool
bpmn_file: str
test_case_identifier: str
test_case_error_details: Optional[TestCaseErrorDetails] = 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",
}
"""
JSON file name:
The name should be in format "test_BPMN_FILE_NAME_IT_TESTS.json".
BPMN_TASK_IDENTIIFER:
can be either task bpmn identifier or in format:
[BPMN_PROCESS_ID]:[TASK_BPMN_IDENTIFIER]
example: 'BasicServiceTaskProcess:service_task_one'
this allows for tasks to share bpmn identifiers across models
which is useful for call activities
DATA for tasks:
This is an array of task data. This allows for the task to
be called multiple times and given different data each time.
This is useful for testing loops where each iteration needs
different input. The test will fail if the task is called
multiple times without task data input for each call.
JSON file format:
{
TEST_CASE_NAME: {
"tasks": {
BPMN_TASK_IDENTIIFER: {
"data": [DATA]
}
},
"expected_output_json": DATA
}
}
"""
class ProcessModelTestRunner:
"""Generic test runner code. May move into own library at some point.
KEEP THIS GENERIC. do not add backend specific code here.
"""
def __init__(
self,
process_model_directory_path: str,
process_model_directory_for_test_discovery: Optional[str] = None,
instantiate_executer_callback: Optional[Callable[[str], Any]] = None,
execute_task_callback: Optional[Callable[[Any, Optional[str], Optional[dict]], Any]] = None,
get_next_task_callback: Optional[Callable[[Any], Any]] = None,
test_case_file: Optional[str] = None,
test_case_identifier: Optional[str] = None,
) -> None:
self.process_model_directory_path = process_model_directory_path
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_file = test_case_file
self.test_case_identifier = test_case_identifier
# keep track of the current task data index
self.task_data_index: dict[str, int] = {}
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 = self.failing_tests()
return len(failed_tests) < 1
def failing_tests(self) -> list[TestCaseResult]:
return [t for t in self.test_case_results if t.passed is False]
def passing_tests(self) -> list[TestCaseResult]:
return [t for t in self.test_case_results if t.passed is True]
def failing_tests_formatted(self) -> str:
formatted_tests = ["FAILING TESTS:"]
for failing_test in self.failing_tests():
msg = ""
if failing_test.test_case_error_details is not None:
msg = "\n\t\t".join(failing_test.test_case_error_details.error_messages)
formatted_tests.append(f"\t{failing_test.bpmn_file}: {failing_test.test_case_identifier}: {msg}")
return "\n".join(formatted_tests)
def run(self) -> None:
if len(self.test_mappings.items()) < 1:
raise NoTestCasesFoundError(
f"Could not find any test cases in given directory: {self.process_model_directory_for_test_discovery}"
)
for json_test_case_file, bpmn_file in self.test_mappings.items():
with open(json_test_case_file) as f:
json_file_contents = json.loads(f.read())
for test_case_identifier, test_case_contents in json_file_contents.items():
if self.test_case_identifier is None or test_case_identifier == self.test_case_identifier:
self.task_data_index = {}
try:
self.run_test_case(bpmn_file, test_case_identifier, test_case_contents)
except Exception as ex:
self._add_test_result(False, bpmn_file, test_case_identifier, exception=ex)
def run_test_case(self, bpmn_file: str, test_case_identifier: str, test_case_contents: dict) -> None:
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_task_properties = None
test_case_task_key = next_task.task_spec.bpmn_id
if "tasks" in test_case_contents:
if test_case_task_key not in test_case_contents["tasks"]:
# we may need to go to the top level workflow of a given bpmn file
test_case_task_key = f"{next_task.workflow.spec.name}:{next_task.task_spec.bpmn_id}"
if test_case_task_key in test_case_contents["tasks"]:
test_case_task_properties = test_case_contents["tasks"][test_case_task_key]
task_type = next_task.task_spec.__class__.__name__
if task_type in ["ServiceTask", "UserTask", "CallActivity"] and test_case_task_properties is None:
raise UnrunnableTestCaseError(
f"Cannot run test case '{test_case_identifier}'. It requires task data for"
f" {next_task.task_spec.bpmn_id} because it is of type '{task_type}'"
)
self._execute_task(next_task, test_case_task_key, test_case_task_properties)
next_task = self._get_next_task(bpmn_process_instance)
error_message = None
if bpmn_process_instance.is_completed() is False:
error_message = [
"Expected process instance to complete but it did not.",
f"Final data was: {bpmn_process_instance.last_task.data}",
f"Last task bpmn id: {bpmn_process_instance.last_task.task_spec.bpmn_id}",
f"Last task type: {bpmn_process_instance.last_task.task_spec.__class__.__name__}",
]
elif bpmn_process_instance.success is False:
error_message = [
"Expected process instance to succeed but it did not.",
f"Final data was: {bpmn_process_instance.data}",
]
elif test_case_contents["expected_output_json"] != bpmn_process_instance.data:
error_message = [
"Expected output did not match actual output:",
f"expected: {test_case_contents['expected_output_json']}",
f"actual: {bpmn_process_instance.data}",
]
self._add_test_result(error_message is None, bpmn_file, test_case_identifier, error_message)
def _discover_process_model_test_cases(
self,
) -> dict[str, str]:
test_mappings = {}
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_norm = os.path.normpath(file)
file_dir = os.path.dirname(file_norm)
json_file_name = os.path.basename(file_norm)
if self.test_case_file is None or json_file_name == self.test_case_file:
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_norm] = bpmn_file_path
else:
raise MissingBpmnFileForTestCaseError(
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)
# if we cannot load process model then ignore it since it can cause errors unrelated
# to the test and if it is related, it will most likely be caught further along the test
try:
root = etree.fromstring(file_contents, parser=etree_xml_parser)
except etree.XMLSyntaxError:
continue
call_activities = root.findall(".//bpmn:callActivity", namespaces=DEFAULT_NSMAP)
for call_activity in call_activities:
if "calledElement" in call_activity.attrib:
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)
if bpmn_process_element is not None:
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_key: Optional[str], test_case_task_properties: Optional[dict]
) -> None:
if self.execute_task_callback:
self.execute_task_callback(spiff_task, test_case_task_key, test_case_task_properties)
self._default_execute_task(spiff_task, test_case_task_key, 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 _default_get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]:
ready_tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY)])
if len(ready_tasks) > 0:
return ready_tasks[0]
return None
def _default_execute_task(
self, spiff_task: SpiffTask, test_case_task_key: Optional[str], test_case_task_properties: Optional[dict]
) -> None:
if spiff_task.task_spec.manual or spiff_task.task_spec.__class__.__name__ == "ServiceTask":
if test_case_task_key and test_case_task_properties and "data" in test_case_task_properties:
if test_case_task_key not in self.task_data_index:
self.task_data_index[test_case_task_key] = 0
task_data_length = len(test_case_task_properties["data"])
test_case_index = self.task_data_index[test_case_task_key]
if task_data_length <= test_case_index:
raise MissingInputTaskData(
f"Missing input task data for task: {test_case_task_key}. "
f"Only {task_data_length} given in the json but task was called {test_case_index + 1} times"
)
spiff_task.update_data(test_case_task_properties["data"][test_case_index])
self.task_data_index[test_case_task_key] += 1
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 _get_relative_path_of_bpmn_file(self, bpmn_file: str) -> str:
return os.path.relpath(bpmn_file, start=self.process_model_directory_path)
def _exception_to_test_case_error_details(
self, exception: Union[Exception, WorkflowTaskException]
) -> TestCaseErrorDetails:
error_messages = str(exception).split("\n")
test_case_error_details = TestCaseErrorDetails(error_messages=error_messages)
if isinstance(exception, WorkflowTaskException):
test_case_error_details.task_error_line = exception.error_line
test_case_error_details.task_trace = exception.task_trace
test_case_error_details.task_line_number = exception.line_number
test_case_error_details.task_bpmn_identifier = exception.task_spec.bpmn_id
test_case_error_details.task_bpmn_name = exception.task_spec.bpmn_name
else:
test_case_error_details.stacktrace = traceback.format_exc().split("\n")
return test_case_error_details
def _add_test_result(
self,
passed: bool,
bpmn_file: str,
test_case_identifier: str,
error_messages: Optional[list[str]] = None,
exception: Optional[Exception] = None,
) -> None:
test_case_error_details = None
if exception is not None:
test_case_error_details = self._exception_to_test_case_error_details(exception)
elif error_messages:
test_case_error_details = TestCaseErrorDetails(error_messages=error_messages)
bpmn_file_relative = self._get_relative_path_of_bpmn_file(bpmn_file)
test_result = TestCaseResult(
passed=passed,
bpmn_file=bpmn_file_relative,
test_case_identifier=test_case_identifier,
test_case_error_details=test_case_error_details,
)
self.test_case_results.append(test_result)
class BpmnFileMissingExecutableProcessError(Exception):
pass
class ProcessModelTestRunnerService:
def __init__(
self,
process_model_directory_path: str,
test_case_file: Optional[str] = None,
test_case_identifier: Optional[str] = None,
) -> None:
self.process_model_test_runner = ProcessModelTestRunner(
process_model_directory_path,
test_case_file=test_case_file,
test_case_identifier=test_case_identifier,
# 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:
self.process_model_test_runner.run()

View File

@ -221,37 +221,37 @@ class SpecFileService(FileSystemService):
return spec_file_data
@staticmethod
def full_file_path(spec: ProcessModelInfo, file_name: str) -> str:
def full_file_path(process_model: ProcessModelInfo, file_name: str) -> str:
"""File_path."""
return os.path.abspath(os.path.join(SpecFileService.workflow_path(spec), file_name))
return os.path.abspath(os.path.join(SpecFileService.process_model_full_path(process_model), file_name))
@staticmethod
def last_modified(spec: ProcessModelInfo, file_name: str) -> datetime:
def last_modified(process_model: ProcessModelInfo, file_name: str) -> datetime:
"""Last_modified."""
full_file_path = SpecFileService.full_file_path(spec, file_name)
full_file_path = SpecFileService.full_file_path(process_model, file_name)
return FileSystemService._last_modified(full_file_path)
@staticmethod
def timestamp(spec: ProcessModelInfo, file_name: str) -> float:
def timestamp(process_model: ProcessModelInfo, file_name: str) -> float:
"""Timestamp."""
full_file_path = SpecFileService.full_file_path(spec, file_name)
full_file_path = SpecFileService.full_file_path(process_model, file_name)
return FileSystemService._timestamp(full_file_path)
@staticmethod
def delete_file(spec: ProcessModelInfo, file_name: str) -> None:
def delete_file(process_model: ProcessModelInfo, file_name: str) -> None:
"""Delete_file."""
# Fixme: Remember to remove the lookup files when the spec file is removed.
# Fixme: Remember to remove the lookup files when the process_model file is removed.
# lookup_files = session.query(LookupFileModel).filter_by(file_model_id=file_id).all()
# for lf in lookup_files:
# session.query(LookupDataModel).filter_by(lookup_file_model_id=lf.id).delete()
# session.query(LookupFileModel).filter_by(id=lf.id).delete()
full_file_path = SpecFileService.full_file_path(spec, file_name)
full_file_path = SpecFileService.full_file_path(process_model, file_name)
os.remove(full_file_path)
@staticmethod
def delete_all_files(spec: ProcessModelInfo) -> None:
def delete_all_files(process_model: ProcessModelInfo) -> None:
"""Delete_all_files."""
dir_path = SpecFileService.workflow_path(spec)
dir_path = SpecFileService.process_model_full_path(process_model)
if os.path.exists(dir_path):
shutil.rmtree(dir_path)

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_FailingProcess" name="Failing Process" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1xkc1ru</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1xkc1ru" sourceRef="StartEvent_1" targetRef="Activity_FailingTask" />
<bpmn:endEvent id="Event_00iauxo">
<bpmn:incoming>Flow_0tkkq9s</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0tkkq9s" sourceRef="Activity_FailingTask" targetRef="Event_00iauxo" />
<bpmn:scriptTask id="Activity_FailingTask" name="Failing Task">
<bpmn:incoming>Flow_1xkc1ru</bpmn:incoming>
<bpmn:outgoing>Flow_0tkkq9s</bpmn:outgoing>
<bpmn:script>a = 1
b = a + 'two'</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_FailingProcess">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_00iauxo_di" bpmnElement="Event_00iauxo">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ecfxz2_di" bpmnElement="Activity_FailingTask">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1xkc1ru_di" bpmnElement="Flow_1xkc1ru">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0tkkq9s_di" bpmnElement="Flow_0tkkq9s">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,11 @@
{
"description": "Process that raises an exception",
"display_name": "Failing Process",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "failing_task.bpmn",
"primary_process_id": "Process_FailingProcess"
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="CallActivityProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0ext5lt</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0ext5lt" sourceRef="StartEvent_1" targetRef="Activity_0irfg4l" />
<bpmn:endEvent id="Event_0bz40ol">
<bpmn:incoming>Flow_1hzwssi</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1hzwssi" sourceRef="Activity_0irfg4l" targetRef="Event_0bz40ol" />
<bpmn:callActivity id="Activity_0irfg4l" name="Call Activity" calledElement="ManualTaskProcess">
<bpmn:incoming>Flow_0ext5lt</bpmn:incoming>
<bpmn:outgoing>Flow_1hzwssi</bpmn:outgoing>
</bpmn:callActivity>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_test_a42_A_4_2_bd2e724">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0bz40ol_di" bpmnElement="Event_0bz40ol">
<dc:Bounds x="422" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0148g78_di" bpmnElement="Activity_0irfg4l">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ext5lt_di" bpmnElement="Flow_0ext5lt">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1hzwssi_di" bpmnElement="Flow_1hzwssi">
<di:waypoint x="370" y="177" />
<di:waypoint x="422" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "",
"display_name": "Call Activity",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"primary_file_name": "call_activity.bpmn",
"primary_process_id": "CallActivityProcess"
}

View File

@ -0,0 +1,5 @@
{
"test_case_1": {
"expected_output_json": {}
}
}

View File

@ -0,0 +1,11 @@
{
"title": "Choose Your Branch",
"description": "",
"properties": {
"branch": {
"type": "string",
"title": "branch"
}
},
"required": []
}

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="exclusive_gateway_based_on_user_task_process" name="ExclusiveGatewayBasedOnUserTaskProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_19j3jcx</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_19j3jcx" sourceRef="StartEvent_1" targetRef="user_task_choose_branch" />
<bpmn:exclusiveGateway id="Gateway_0xwvfep" default="Flow_10m4g0q">
<bpmn:incoming>Flow_0qa66xz</bpmn:incoming>
<bpmn:outgoing>Flow_1ww41l3</bpmn:outgoing>
<bpmn:outgoing>Flow_10m4g0q</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:sequenceFlow id="Flow_0qa66xz" sourceRef="user_task_choose_branch" targetRef="Gateway_0xwvfep" />
<bpmn:sequenceFlow id="Flow_1ww41l3" sourceRef="Gateway_0xwvfep" targetRef="script_task_branch_a">
<bpmn:conditionExpression>branch == 'a'</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="Flow_10m4g0q" sourceRef="Gateway_0xwvfep" targetRef="script_task_branch_b" />
<bpmn:endEvent id="Event_05ovp79">
<bpmn:incoming>Flow_1oxbb75</bpmn:incoming>
<bpmn:incoming>Flow_1ck9lfk</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1oxbb75" sourceRef="script_task_branch_b" targetRef="Event_05ovp79" />
<bpmn:sequenceFlow id="Flow_1ck9lfk" sourceRef="script_task_branch_a" targetRef="Event_05ovp79" />
<bpmn:userTask id="user_task_choose_branch" name="User Task Choose Branch">
<bpmn:extensionElements>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="choose-your-branch-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="choose-your-branch-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_19j3jcx</bpmn:incoming>
<bpmn:outgoing>Flow_0qa66xz</bpmn:outgoing>
</bpmn:userTask>
<bpmn:scriptTask id="script_task_branch_a" name="Script Task Branch A">
<bpmn:incoming>Flow_1ww41l3</bpmn:incoming>
<bpmn:outgoing>Flow_1ck9lfk</bpmn:outgoing>
<bpmn:script>chosen_branch = 'A'</bpmn:script>
</bpmn:scriptTask>
<bpmn:scriptTask id="script_task_branch_b" name="Script Task Branch B">
<bpmn:incoming>Flow_10m4g0q</bpmn:incoming>
<bpmn:outgoing>Flow_1oxbb75</bpmn:outgoing>
<bpmn:script>chosen_branch = 'B'</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="exclusive_gateway_based_on_user_task_process">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0xwvfep_di" bpmnElement="Gateway_0xwvfep" isMarkerVisible="true">
<dc:Bounds x="425" y="152" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_05ovp79_di" bpmnElement="Event_05ovp79">
<dc:Bounds x="562" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_15rk06j_di" bpmnElement="user_task_choose_branch">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0oy60uv_di" bpmnElement="script_task_branch_a">
<dc:Bounds x="500" y="20" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_02hkehe_di" bpmnElement="script_task_branch_b">
<dc:Bounds x="500" y="260" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_19j3jcx_di" bpmnElement="Flow_19j3jcx">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0qa66xz_di" bpmnElement="Flow_0qa66xz">
<di:waypoint x="370" y="177" />
<di:waypoint x="425" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1ww41l3_di" bpmnElement="Flow_1ww41l3">
<di:waypoint x="450" y="152" />
<di:waypoint x="450" y="60" />
<di:waypoint x="500" y="60" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_10m4g0q_di" bpmnElement="Flow_10m4g0q">
<di:waypoint x="450" y="202" />
<di:waypoint x="450" y="300" />
<di:waypoint x="500" y="300" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1oxbb75_di" bpmnElement="Flow_1oxbb75">
<di:waypoint x="550" y="260" />
<di:waypoint x="550" y="233" />
<di:waypoint x="580" y="233" />
<di:waypoint x="580" y="195" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1ck9lfk_di" bpmnElement="Flow_1ck9lfk">
<di:waypoint x="550" y="100" />
<di:waypoint x="550" y="130" />
<di:waypoint x="580" y="130" />
<di:waypoint x="580" y="159" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,11 @@
{
"description": "",
"display_name": "Exclusive Gateway Based on User Task",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "exclusive_gateway_based_on_user_task.bpmn",
"primary_process_id": "exclusive_gateway_based_on_user_task_process"
}

View File

@ -0,0 +1,22 @@
{
"test_case_one": {
"tasks": {
"user_task_choose_branch": {
"data": [
{ "branch": "a" }
]
}
},
"expected_output_json": { "branch": "a", "chosen_branch": "A"}
},
"test_case_two": {
"tasks": {
"user_task_choose_branch": {
"data": [
{ "branch": "b" }
]
}
},
"expected_output_json": { "branch": "b", "chosen_branch": "B"}
}
}

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="loopback_to_user_task_process" name="Loopback To User Task Process" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_12xxe7w</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_12xxe7w" sourceRef="StartEvent_1" targetRef="set_variable" />
<bpmn:exclusiveGateway id="Gateway_1gap20a" default="Flow_1sg0c65">
<bpmn:incoming>Flow_1s3znr2</bpmn:incoming>
<bpmn:outgoing>Flow_0utss6p</bpmn:outgoing>
<bpmn:outgoing>Flow_1sg0c65</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:sequenceFlow id="Flow_08tc3r7" sourceRef="set_variable" targetRef="user_task_enter_increment" />
<bpmn:endEvent id="Event_1il3y5o">
<bpmn:incoming>Flow_0utss6p</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0utss6p" sourceRef="Gateway_1gap20a" targetRef="Event_1il3y5o">
<bpmn:conditionExpression>counter == 3</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:scriptTask id="set_variable" name="Set Variable">
<bpmn:incoming>Flow_12xxe7w</bpmn:incoming>
<bpmn:outgoing>Flow_08tc3r7</bpmn:outgoing>
<bpmn:script>counter = 1
the_var = 0</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_0wnc5ju" sourceRef="user_task_enter_increment" targetRef="add_user_input_to_variable" />
<bpmn:sequenceFlow id="Flow_1sg0c65" sourceRef="Gateway_1gap20a" targetRef="user_task_enter_increment" />
<bpmn:userTask id="user_task_enter_increment" name="User Task Enter Increment">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser />
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="user-input-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="user-input-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_08tc3r7</bpmn:incoming>
<bpmn:incoming>Flow_1sg0c65</bpmn:incoming>
<bpmn:outgoing>Flow_0wnc5ju</bpmn:outgoing>
</bpmn:userTask>
<bpmn:sequenceFlow id="Flow_1s3znr2" sourceRef="add_user_input_to_variable" targetRef="Gateway_1gap20a" />
<bpmn:scriptTask id="add_user_input_to_variable" name="Add User Input to Variable">
<bpmn:incoming>Flow_0wnc5ju</bpmn:incoming>
<bpmn:outgoing>Flow_1s3znr2</bpmn:outgoing>
<bpmn:script>the_var = user_input_variable + the_var
counter += 1</bpmn:script>
</bpmn:scriptTask>
<bpmn:textAnnotation id="TextAnnotation_09y70ug">
<bpmn:text>loop back if a &lt; 3</bpmn:text>
</bpmn:textAnnotation>
<bpmn:association id="Association_0470wt9" sourceRef="Flow_1sg0c65" targetRef="TextAnnotation_09y70ug" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="loopback_to_user_task_process">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1gap20a_di" bpmnElement="Gateway_1gap20a" isMarkerVisible="true">
<dc:Bounds x="625" y="152" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1il3y5o_di" bpmnElement="Event_1il3y5o">
<dc:Bounds x="712" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0hrsdn8_di" bpmnElement="set_variable">
<dc:Bounds x="250" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0katfaf_di" bpmnElement="user_task_enter_increment">
<dc:Bounds x="380" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0a6owe7_di" bpmnElement="add_user_input_to_variable">
<dc:Bounds x="500" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="TextAnnotation_09y70ug_di" bpmnElement="TextAnnotation_09y70ug">
<dc:Bounds x="610" y="55" width="130" height="30" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_12xxe7w_di" bpmnElement="Flow_12xxe7w">
<di:waypoint x="215" y="177" />
<di:waypoint x="250" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_08tc3r7_di" bpmnElement="Flow_08tc3r7">
<di:waypoint x="350" y="177" />
<di:waypoint x="380" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0utss6p_di" bpmnElement="Flow_0utss6p">
<di:waypoint x="675" y="177" />
<di:waypoint x="712" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0wnc5ju_di" bpmnElement="Flow_0wnc5ju">
<di:waypoint x="480" y="177" />
<di:waypoint x="500" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1sg0c65_di" bpmnElement="Flow_1sg0c65">
<di:waypoint x="650" y="150" />
<di:waypoint x="550" y="70" />
<di:waypoint x="475" y="139" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1s3znr2_di" bpmnElement="Flow_1s3znr2">
<di:waypoint x="600" y="177" />
<di:waypoint x="625" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Association_0470wt9_di" bpmnElement="Association_0470wt9">
<di:waypoint x="579" y="93" />
<di:waypoint x="610" y="81" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,11 @@
{
"description": "",
"display_name": "Loopback to User Task",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "loopback_to_user_task.bpmn",
"primary_process_id": "loopback_to_user_task_process"
}

View File

@ -0,0 +1,13 @@
{
"test_case_one": {
"tasks": {
"user_task_enter_increment": {
"data": [
{ "user_input_variable": 7 },
{ "user_input_variable": 8 }
]
}
},
"expected_output_json": { "the_var": 15, "counter": 3, "user_input_variable": 8 }
}
}

View File

@ -0,0 +1,11 @@
{
"title": "User Input",
"description": "",
"properties": {
"user_input_variable": {
"type": "integer",
"title": "user_input_variable"
}
},
"required": []
}

View File

@ -0,0 +1,5 @@
{
"ui:order": [
"user_input_variable"
]
}

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="loopback_process" name="Loopback Process" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_12xxe7w</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_12xxe7w" sourceRef="StartEvent_1" targetRef="set_variable" />
<bpmn:exclusiveGateway id="Gateway_1gap20a" default="Flow_1sg0c65">
<bpmn:incoming>Flow_0wnc5ju</bpmn:incoming>
<bpmn:outgoing>Flow_0utss6p</bpmn:outgoing>
<bpmn:outgoing>Flow_1sg0c65</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:sequenceFlow id="Flow_08tc3r7" sourceRef="set_variable" targetRef="increment_variable" />
<bpmn:endEvent id="Event_1il3y5o">
<bpmn:incoming>Flow_0utss6p</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0utss6p" sourceRef="Gateway_1gap20a" targetRef="Event_1il3y5o">
<bpmn:conditionExpression>a == 3</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:scriptTask id="set_variable" name="Set Variable">
<bpmn:incoming>Flow_12xxe7w</bpmn:incoming>
<bpmn:outgoing>Flow_08tc3r7</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_0wnc5ju" sourceRef="increment_variable" targetRef="Gateway_1gap20a" />
<bpmn:scriptTask id="increment_variable" name="Increment Variable">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser />
</bpmn:extensionElements>
<bpmn:incoming>Flow_08tc3r7</bpmn:incoming>
<bpmn:incoming>Flow_1sg0c65</bpmn:incoming>
<bpmn:outgoing>Flow_0wnc5ju</bpmn:outgoing>
<bpmn:script>a += 1</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_1sg0c65" sourceRef="Gateway_1gap20a" targetRef="increment_variable" />
<bpmn:textAnnotation id="TextAnnotation_09y70ug">
<bpmn:text>loop back if a &lt; 3</bpmn:text>
</bpmn:textAnnotation>
<bpmn:association id="Association_0470wt9" sourceRef="Flow_1sg0c65" targetRef="TextAnnotation_09y70ug" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="loopback_process">
<bpmndi:BPMNShape id="TextAnnotation_09y70ug_di" bpmnElement="TextAnnotation_09y70ug">
<dc:Bounds x="610" y="55" width="130" height="30" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1gap20a_di" bpmnElement="Gateway_1gap20a" isMarkerVisible="true">
<dc:Bounds x="535" y="152" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1il3y5o_di" bpmnElement="Event_1il3y5o">
<dc:Bounds x="632" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0hrsdn8_di" bpmnElement="set_variable">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1g5b8wo_di" bpmnElement="increment_variable">
<dc:Bounds x="400" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Association_0470wt9_di" bpmnElement="Association_0470wt9">
<di:waypoint x="567.1081954098089" y="89.9595613114437" />
<di:waypoint x="610" y="81" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_12xxe7w_di" bpmnElement="Flow_12xxe7w">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_08tc3r7_di" bpmnElement="Flow_08tc3r7">
<di:waypoint x="370" y="177" />
<di:waypoint x="400" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0utss6p_di" bpmnElement="Flow_0utss6p">
<di:waypoint x="585" y="177" />
<di:waypoint x="632" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0wnc5ju_di" bpmnElement="Flow_0wnc5ju">
<di:waypoint x="500" y="177" />
<di:waypoint x="535" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1sg0c65_di" bpmnElement="Flow_1sg0c65">
<di:waypoint x="560" y="150" />
<di:waypoint x="610" y="140" />
<di:waypoint x="550" y="70" />
<di:waypoint x="489" y="137" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,11 @@
{
"description": "",
"display_name": "Loopback",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "loopback.bpmn",
"primary_process_id": "loopback_process"
}

View File

@ -0,0 +1,5 @@
{
"test_case_1": {
"expected_output_json": { "a": 3 }
}
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="ManualTaskProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0gz6i84</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0gz6i84" sourceRef="StartEvent_1" targetRef="manual_task_one" />
<bpmn:endEvent id="Event_0ynlmo7">
<bpmn:incoming>Flow_0ikklg6</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0ikklg6" sourceRef="manual_task_one" targetRef="Event_0ynlmo7" />
<bpmn:manualTask id="manual_task_one" name="Manual">
<bpmn:incoming>Flow_0gz6i84</bpmn:incoming>
<bpmn:outgoing>Flow_0ikklg6</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="ManualTaskProcess">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0ynlmo7_di" bpmnElement="Event_0ynlmo7">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0sji2rz_di" bpmnElement="manualt_task_one">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0gz6i84_di" bpmnElement="Flow_0gz6i84">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikklg6_di" bpmnElement="Flow_0ikklg6">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "Manual Task",
"display_name": "Manual Task",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"primary_file_name": "manual_task.bpmn",
"primary_process_id": "ManualTaskProcess"
}

View File

@ -0,0 +1,10 @@
{
"test_case_1": {
"tasks": {
"manual_task_one": {
"data": [{}]
}
},
"expected_output_json": {}
}
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="ProcessA" name="ProcessA" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0jk46kf</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0jk46kf" sourceRef="StartEvent_1" targetRef="Activity_0e9rl60" />
<bpmn:endEvent id="Event_1srknca">
<bpmn:incoming>Flow_0pw6euz</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0pw6euz" sourceRef="Activity_0e9rl60" targetRef="Event_1srknca" />
<bpmn:scriptTask id="Activity_0e9rl60">
<bpmn:incoming>Flow_0jk46kf</bpmn:incoming>
<bpmn:outgoing>Flow_0pw6euz</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="ProcessA">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1srknca_di" bpmnElement="Event_1srknca">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0yxs81w_di" bpmnElement="Activity_0e9rl60">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0jk46kf_di" bpmnElement="Flow_0jk46kf">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0pw6euz_di" bpmnElement="Flow_0pw6euz">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_39edgqg" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1qgv480</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1qgv480" sourceRef="StartEvent_1" targetRef="Activity_1kral0x" />
<bpmn:endEvent id="ProcessB" name="ProcessB">
<bpmn:incoming>Flow_1sbj39z</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1sbj39z" sourceRef="Activity_1kral0x" targetRef="ProcessB" />
<bpmn:scriptTask id="Activity_1kral0x">
<bpmn:incoming>Flow_1qgv480</bpmn:incoming>
<bpmn:outgoing>Flow_1sbj39z</bpmn:outgoing>
<bpmn:script>b = 1</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_39edgqg">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_12lq7sg_di" bpmnElement="ProcessB">
<dc:Bounds x="432" y="159" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="427" y="202" width="48" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0pkm1sr_di" bpmnElement="Activity_1kral0x">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1qgv480_di" bpmnElement="Flow_1qgv480">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1sbj39z_di" bpmnElement="Flow_1sbj39z">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,11 @@
{
"description": "",
"display_name": "Multiple Test Files",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "a.bpmn",
"primary_process_id": "ProcessA"
}

View File

@ -0,0 +1,5 @@
{
"test_case_1": {
"expected_output_json": { "a": 1 }
}
}

View File

@ -0,0 +1,8 @@
{
"test_case_1": {
"expected_output_json": { "b": 1 }
},
"test_case_2": {
"expected_output_json": { "b": 1 }
}
}

View File

@ -0,0 +1,9 @@
{
"admin": false,
"description": "",
"display_name": "Expected To Pass",
"display_order": 0,
"parent_groups": null,
"process_groups": [],
"process_models": []
}

View File

@ -0,0 +1,11 @@
{
"description": "",
"display_name": "Script Task",
"display_order": 0,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"metadata_extraction_paths": null,
"primary_file_name": "Script.bpmn",
"primary_process_id": "Process_Script_Task"
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_Script_Task" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0qfycuk</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0qfycuk" sourceRef="StartEvent_1" targetRef="Activity_1qdbp6x" />
<bpmn:endEvent id="Event_1kumwb5">
<bpmn:incoming>Flow_1auiekw</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1auiekw" sourceRef="Activity_1qdbp6x" targetRef="Event_1kumwb5" />
<bpmn:scriptTask id="Activity_1qdbp6x" name="Script">
<bpmn:incoming>Flow_0qfycuk</bpmn:incoming>
<bpmn:outgoing>Flow_1auiekw</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_Script_Task">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1kumwb5_di" bpmnElement="Event_1kumwb5">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ii0b3p_di" bpmnElement="Activity_1qdbp6x">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0qfycuk_di" bpmnElement="Flow_0qfycuk">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1auiekw_di" bpmnElement="Flow_1auiekw">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,5 @@
{
"test_case_1": {
"expected_output_json": { "a": 1 }
}
}

View File

@ -0,0 +1,10 @@
{
"description": "A.1.0.2",
"display_name": "A.1.0.2 - Service Task",
"display_order": 13,
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"primary_file_name": "A.1.0.2.bpmn",
"primary_process_id": "Process_test_a102_A_1_0_2_bd2e724"
}

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="ServiceTaskProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_19ephzh</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_19ephzh" sourceRef="StartEvent_1" targetRef="service_task_one" />
<bpmn:endEvent id="Event_132m0z7">
<bpmn:incoming>Flow_1dsxn78</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1dsxn78" sourceRef="service_task_one" targetRef="Event_132m0z7" />
<bpmn:serviceTask id="service_task_one" name="Task 2">
<bpmn:extensionElements>
<spiffworkflow:serviceTaskOperator id="http/GetRequest" resultVariable="bamboo_get_employee">
<spiffworkflow:parameters>
<spiffworkflow:parameter id="basic_auth_password" type="str" value="&#34;x&#34;" />
<spiffworkflow:parameter id="basic_auth_username" type="str" value="&#34;x&#34;" />
<spiffworkflow:parameter id="headers" type="any" value="{&#34;Accept&#34;: &#34;application/json&#34;}" />
<spiffworkflow:parameter id="params" type="any" value="{}" />
<spiffworkflow:parameter id="url" type="str" value="f&#34;https://example.com/api/user&#34;" />
</spiffworkflow:parameters>
</spiffworkflow:serviceTaskOperator>
<spiffworkflow:instructionsForEndUser>This is the Service Task Unit Test Screen.</spiffworkflow:instructionsForEndUser>
<spiffworkflow:postScript />
</bpmn:extensionElements>
<bpmn:incoming>Flow_0xx2kop</bpmn:incoming>
<bpmn:outgoing>Flow_1dsxn78</bpmn:outgoing>
</bpmn:serviceTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="ServiceTaskProcess">
<bpmndi:BPMNEdge id="Flow_19ephzh_di" bpmnElement="Flow_19ephzh">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0xx2kop_di" bpmnElement="Flow_0xx2kop">
<di:waypoint x="370" y="177" />
<di:waypoint x="430" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1dsxn78_di" bpmnElement="Flow_1dsxn78">
<di:waypoint x="530" y="177" />
<di:waypoint x="592" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_132m0z7_di" bpmnElement="Event_132m0z7">
<dc:Bounds x="592" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1nlg9cc_di" bpmnElement="service_task_one">
<dc:Bounds x="430" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,10 @@
{
"test_case_1": {
"tasks": {
"ServiceTaskProcess:service_task_one": {
"data": [{ "the_result": "result_from_service" }]
}
},
"expected_output_json": { "the_result": "result_from_service" }
}
}

View File

@ -12,12 +12,10 @@ from spiffworkflow_backend.services.process_model_service import ProcessModelSer
def assure_process_group_exists(process_group_id: Optional[str] = None) -> ProcessGroup:
"""Assure_process_group_exists."""
process_group = None
process_model_service = ProcessModelService()
if process_group_id is not None:
try:
process_group = process_model_service.get_process_group(process_group_id)
process_group = ProcessModelService.get_process_group(process_group_id)
except ProcessEntityNotFoundError:
process_group = None
@ -31,7 +29,7 @@ def assure_process_group_exists(process_group_id: Optional[str] = None) -> Proce
admin=False,
display_order=0,
)
process_model_service.add_process_group(process_group)
ProcessModelService.add_process_group(process_group)
return process_group

View File

@ -0,0 +1,130 @@
import os
from typing import Optional
import pytest
from flask import current_app
from flask import Flask
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from spiffworkflow_backend.services.process_model_test_runner_service import NoTestCasesFoundError
from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunner
class TestProcessModelTestRunner(BaseTest):
def test_can_test_a_simple_process_model(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests("script-task")
assert len(process_model_test_runner.test_case_results) == 1
def test_will_raise_if_no_tests_found(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = ProcessModelTestRunner(os.path.join(self.root_path(), "DNE"))
with pytest.raises(NoTestCasesFoundError):
process_model_test_runner.run()
assert process_model_test_runner.all_test_cases_passed(), process_model_test_runner.test_case_results
def test_can_test_multiple_process_models_with_all_passing_tests(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests()
assert len(process_model_test_runner.test_case_results) > 1
def test_can_test_multiple_process_models_with_failing_tests(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests(parent_directory="expected-to-fail")
assert len(process_model_test_runner.test_case_results) == 1
def test_can_test_process_model_with_multiple_files(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests(bpmn_process_directory_name="multiple-test-files")
assert len(process_model_test_runner.test_case_results) == 3
process_model_test_runner = self._run_model_tests(
bpmn_process_directory_name="multiple-test-files", test_case_file="test_a.json"
)
assert len(process_model_test_runner.test_case_results) == 1
process_model_test_runner = self._run_model_tests(
bpmn_process_directory_name="multiple-test-files", test_case_file="test_b.json"
)
assert len(process_model_test_runner.test_case_results) == 2
process_model_test_runner = self._run_model_tests(
bpmn_process_directory_name="multiple-test-files",
test_case_file="test_b.json",
test_case_identifier="test_case_2",
)
assert len(process_model_test_runner.test_case_results) == 1
def test_can_test_process_model_call_activity(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests(bpmn_process_directory_name="call-activity")
assert len(process_model_test_runner.test_case_results) == 1
def test_can_test_process_model_with_service_task(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests(bpmn_process_directory_name="service-task")
assert len(process_model_test_runner.test_case_results) == 1
def test_can_test_process_model_with_loopback_to_user_task(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model_test_runner = self._run_model_tests(bpmn_process_directory_name="loopback-to-user-task")
assert len(process_model_test_runner.test_case_results) == 1
def _run_model_tests(
self,
bpmn_process_directory_name: Optional[str] = None,
parent_directory: str = "expected-to-pass",
test_case_file: Optional[str] = None,
test_case_identifier: Optional[str] = None,
) -> ProcessModelTestRunner:
base_process_model_dir_path_segments = [self.root_path(), parent_directory]
path_segments = base_process_model_dir_path_segments
if bpmn_process_directory_name:
path_segments = path_segments + [bpmn_process_directory_name]
process_model_test_runner = ProcessModelTestRunner(
process_model_directory_path=os.path.join(*base_process_model_dir_path_segments),
process_model_directory_for_test_discovery=os.path.join(*path_segments),
test_case_file=test_case_file,
test_case_identifier=test_case_identifier,
)
process_model_test_runner.run()
all_tests_expected_to_pass = parent_directory == "expected-to-pass"
assert (
process_model_test_runner.all_test_cases_passed() is all_tests_expected_to_pass
), process_model_test_runner.failing_tests_formatted()
return process_model_test_runner
def root_path(self) -> str:
return os.path.join(
current_app.instance_path,
"..",
"..",
"tests",
"data",
"bpmn_unit_test_process_models",
)

View File

@ -4,6 +4,7 @@ import {
ErrorForDisplay,
ProcessInstanceEventErrorDetail,
ProcessInstanceLogEntry,
TestCaseErrorDetails,
} from '../interfaces';
function errorDetailDisplay(
@ -40,6 +41,22 @@ export const errorForDisplayFromProcessInstanceErrorDetail = (
return errorForDisplay;
};
export const errorForDisplayFromTestCaseErrorDetails = (
testCaseErrorDetails: TestCaseErrorDetails
) => {
const errorForDisplay: ErrorForDisplay = {
message: testCaseErrorDetails.error_messages.join('\n'),
messageClassName: 'failure-string',
task_name: testCaseErrorDetails.task_bpmn_name,
task_id: testCaseErrorDetails.task_bpmn_identifier,
line_number: testCaseErrorDetails.task_line_number,
error_line: testCaseErrorDetails.task_line_contents,
task_trace: testCaseErrorDetails.task_trace,
stacktrace: testCaseErrorDetails.stacktrace,
};
return errorForDisplay;
};
export const childrenForErrorObject = (errorObject: ErrorForDisplay) => {
let sentryLinkTag = null;
if (errorObject.sentry_link) {

View File

@ -0,0 +1,188 @@
import { PlayOutline, Checkmark, Close } from '@carbon/icons-react';
import { Button, Modal } from '@carbon/react';
import { useState } from 'react';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import HttpService from '../services/HttpService';
import { ProcessFile, TestCaseResult, TestCaseResults } from '../interfaces';
import {
childrenForErrorObject,
errorForDisplayFromTestCaseErrorDetails,
} from './ErrorDisplay';
type OwnProps = {
processModelFile?: ProcessFile;
buttonType?: string;
};
export default function ProcessModelTestRun({
processModelFile,
buttonType = 'icon',
}: OwnProps) {
const [testCaseResults, setTestCaseResults] =
useState<TestCaseResults | null>(null);
const [showTestCaseResultsModal, setShowTestCaseResultsModal] =
useState<boolean>(false);
const { targetUris } = useUriListForPermissions();
const onProcessModelTestRunSuccess = (result: TestCaseResults) => {
setTestCaseResults(result);
};
const processModelTestRunResultTag = () => {
if (testCaseResults) {
if (testCaseResults.all_passed) {
return (
<Button
kind="ghost"
className="green-icon"
renderIcon={Checkmark}
iconDescription="All BPMN unit tests passed"
hasIconOnly
size="lg"
onClick={() => setShowTestCaseResultsModal(true)}
/>
);
}
return (
<Button
kind="ghost"
className="red-icon"
renderIcon={Close}
iconDescription="BPMN unit tests failed"
hasIconOnly
size="lg"
onClick={() => setShowTestCaseResultsModal(true)}
/>
);
}
return null;
};
const onProcessModelTestRun = () => {
const httpMethod = 'POST';
setTestCaseResults(null);
let queryParams = '';
if (processModelFile) {
queryParams = `?test_case_file=${processModelFile.name}`;
}
HttpService.makeCallToBackend({
path: `${targetUris.processModelTestsPath}${queryParams}`,
successCallback: onProcessModelTestRunSuccess,
httpMethod,
});
};
const testCaseFormattedResultTag = () => {
if (!testCaseResults) {
return null;
}
const passingRows: any[] = [];
const failingRows: any[] = [];
testCaseResults.passing.forEach((testCaseResult: TestCaseResult) => {
passingRows.push(<p>{testCaseResult.test_case_identifier}</p>);
});
testCaseResults.failing
.slice(0, 2)
.forEach((testCaseResult: TestCaseResult) => {
if (testCaseResult.test_case_error_details) {
const errorForDisplay = errorForDisplayFromTestCaseErrorDetails(
testCaseResult.test_case_error_details
);
const errorChildren = childrenForErrorObject(errorForDisplay);
failingRows.push(
<>
<br />
<p>
Test Case:{' '}
<strong>{testCaseResult.test_case_identifier}</strong>
</p>
{errorChildren}
</>
);
}
});
return (
<>
<p>Passing: {testCaseResults.passing.length}</p>
<p>Failing: {testCaseResults.failing.length}</p>
<br />
{failingRows.length > 0 ? (
<>
<p>Failure Details:</p>
{failingRows}
</>
) : null}
{passingRows.length > 0 ? (
<>
<p>Successful Test Cases:</p>
{passingRows}
</>
) : null}
</>
);
};
const testCaseResultsModal = () => {
if (!testCaseResults) {
return null;
}
let modalHeading = 'All Tests PASSED';
if (!testCaseResults.all_passed) {
modalHeading = 'Some Tests FAILED';
}
return (
<Modal
open={showTestCaseResultsModal}
data-qa="test-case-results-modal"
modalHeading={modalHeading}
modalLabel="Test Case Rsults"
primaryButtonText="OK"
onRequestSubmit={() => setShowTestCaseResultsModal(false)}
onRequestClose={() => setShowTestCaseResultsModal(false)}
>
{testCaseFormattedResultTag()}
</Modal>
);
};
const buttonElement = () => {
if (buttonType === 'icon') {
return (
<Button
kind="ghost"
renderIcon={PlayOutline}
iconDescription="Run BPMN unit tests defined in this file"
hasIconOnly
size="lg"
onClick={() => onProcessModelTestRun()}
/>
);
}
if (buttonType === 'text') {
return (
<Button
onClick={() => onProcessModelTestRun()}
title="Run all BPMN unit tests for this process model"
>
Run Unit Tests
</Button>
);
}
return null;
};
return (
<>
{testCaseResultsModal()}
{buttonElement()}
{processModelTestRunResultTag()}
</>
);
}

View File

@ -29,6 +29,7 @@ export const useUriListForPermissions = () => {
processModelFileShowPath: `/v1.0/process-models/${params.process_model_id}/files/${params.file_name}`,
processModelPublishPath: `/v1.0/process-model-publish/${params.process_model_id}`,
processModelShowPath: `/v1.0/process-models/${params.process_model_id}`,
processModelTestsPath: `/v1.0/process-model-tests/${params.process_model_id}`,
secretListPath: `/v1.0/secrets`,
userSearch: `/v1.0/users/search`,
userExists: `/v1.0/users/exists/by-username`,

View File

@ -418,6 +418,12 @@ td.actions-cell {
padding-bottom: 10px;
}
.cds--btn--ghost:not([disabled]).red-icon svg {
fill: red;
}
.cds--btn--ghost:not([disabled]).green-icon svg {
fill: #198038;
}
.cds--btn--ghost:not([disabled]) svg.red-icon {
fill: red;
}

View File

@ -365,3 +365,26 @@ export interface InterstitialPageResponse {
task?: ProcessInstanceTask;
process_instance?: ProcessInstance;
}
export interface TestCaseErrorDetails {
error_messages: string[];
stacktrace?: string[];
task_bpmn_identifier?: string;
task_bpmn_name?: string;
task_line_contents?: string;
task_line_number?: number;
task_trace?: string[];
}
export interface TestCaseResult {
bpmn_file: string;
passed: boolean;
test_case_identifier: string;
test_case_error_details?: TestCaseErrorDetails;
}
export interface TestCaseResults {
all_passed: boolean;
failing: TestCaseResult[];
passing: TestCaseResult[];
}

View File

@ -2,11 +2,11 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
Add,
Upload,
Download,
TrashCan,
Favorite,
Edit,
Favorite,
TrashCan,
Upload,
View,
// @ts-ignore
} from '@carbon/icons-react';
@ -14,18 +14,18 @@ import {
Accordion,
AccordionItem,
Button,
Grid,
Column,
Stack,
ButtonSet,
Modal,
Column,
FileUploader,
Grid,
Modal,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell,
TableBody,
// @ts-ignore
} from '@carbon/react';
import { Can } from '@casl/react';
@ -49,6 +49,7 @@ import { usePermissionFetcher } from '../hooks/PermissionService';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import ProcessInstanceRun from '../components/ProcessInstanceRun';
import { Notification } from '../components/Notification';
import ProcessModelTestRun from '../components/ProcessModelTestRun';
export default function ProcessModelShow() {
const params = useParams();
@ -68,6 +69,7 @@ export default function ProcessModelShow() {
const { targetUris } = useUriListForPermissions();
const permissionRequestData: PermissionsToCheck = {
[targetUris.processModelShowPath]: ['PUT', 'DELETE'],
[targetUris.processModelTestsPath]: ['POST'],
[targetUris.processModelPublishPath]: ['POST'],
[targetUris.processInstanceListPath]: ['GET'],
[targetUris.processInstanceCreatePath]: ['POST'],
@ -81,6 +83,18 @@ export default function ProcessModelShow() {
`${params.process_model_id}`
);
let hasTestCaseFiles: boolean = false;
const isTestCaseFile = (processModelFile: ProcessFile) => {
return processModelFile.name.match(/^test_.*\.json$/);
};
if (processModel) {
hasTestCaseFiles = !!processModel.files.find(
(processModelFile: ProcessFile) => isTestCaseFile(processModelFile)
);
}
useEffect(() => {
const processResult = (result: ProcessModel) => {
setProcessModel(result);
@ -308,6 +322,13 @@ export default function ProcessModelShow() {
</Can>
);
}
if (isTestCaseFile(processModelFile)) {
elements.push(
<Can I="POST" a={targetUris.processModelTestsPath} ability={ability}>
<ProcessModelTestRun processModelFile={processModelFile} />
</Can>
);
}
return elements;
};
@ -647,6 +668,11 @@ export default function ProcessModelShow() {
Publish Changes
</Button>
</Can>
<Can I="POST" a={targetUris.processModelTestsPath} ability={ability}>
{hasTestCaseFiles ? (
<ProcessModelTestRun buttonType="text" />
) : null}
</Can>
</Stack>
{processModelFilesSection()}
<Can I="GET" a={targetUris.processInstanceListPath} ability={ability}>