We have recently moved to a formal DEV/QA/PROD instance for application development due to the fact we are in a regulated industry (Med Device).
Migrating applications between instances still has its rough edges, such as app completions with app/step changes. This made me think about how I might detect specific issues in applications now that I have access to the underlying JSON file via an Export.
With the help of Grok 3 I developed a couple of python scripts, one that performs the tests I want using the python unittest module, and then a simple GUI wrapper that I intend to package up into a standalone executable for other to use.
Here are the conditions I wanted to test for:
Save All DataandComplete App...in the same trigger clause creating redundant completion data. (ERROR)Comple App with Step Change...which do not survive migration and must be fixed up after import. (WARNING)- Untitled Steps (ERROR)
- Untitled Triggers (ERROR)
- Disabled Triggers (WARNING)
I just realized I’m missing a test, a Clear Variables or Clear Record Placeholders in the same clause as a Save All Data or Comple App... since that would be rather useless.
Here’s a screenshot of the app in action:
I may copy to to my personal github at some point but for now here’s the files. Feedback welcomed, especially @Geoff.Winkley.
apptest.py
# apptest.py
import sys
import zipfile
import json
import unittest
class TestAppJson(unittest.TestCase):
json_data = None # Can be set by GUI or loaded in setUpClass
test_outcomes = {} # Class-level dict to store test outcome types
@classmethod
def setUpClass(cls):
"""
Load JSON data from zip file if provided as a command-line argument,
unless json_data is already set (e.g., by GUI).
"""
if cls.json_data is None: # Only load if not already set
if len(sys.argv) != 2:
raise ValueError("Usage: python apptest.py <zipfile>")
zip_path = sys.argv[1]
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
with zip_ref.open('app.json') as json_file:
cls.json_data = json.load(json_file)
@classmethod
def register_test_outcome(cls, test_name, outcome_type):
"""Register the outcome type (error or warning) for a test."""
cls.test_outcomes[test_name] = outcome_type
def test_no_untitled_trigger(self):
"""Test that there are no untitled triggers."""
self.register_test_outcome('test_no_untitled_trigger', 'error')
triggers = self.json_data.get("triggers", [])
for trigger in triggers:
description = trigger.get("description", [])
if description == "":
self.fail("Found untitled trigger")
def test_disabled_trigger(self):
"""Test for disabled triggers."""
self.register_test_outcome('test_disabled_trigger', 'warning')
triggers = self.json_data.get("triggers", [])
for trigger in triggers:
disabled = trigger.get("disabled", False) # Default to False, not []
if disabled:
self.fail("Found disabled trigger")
def test_no_untitled_step(self):
"""Test that there are no untitled steps."""
self.register_test_outcome('test_no_untitled_step', 'error')
steps = self.json_data.get("steps", [])
for step in steps:
name = step.get("name", [])
if name == "":
self.fail("Found Untitled Step")
def test_no_conflicting_actions(self):
"""Test that no clause has both 'save_process_data' and 'complete_process' actions."""
self.register_test_outcome('test_no_conflicting_actions', 'error')
triggers = self.json_data.get("triggers", [])
for trigger in triggers:
clauses = trigger.get("clauses", [])
for clause in clauses:
actions = clause.get("actions", [])
action_types = {action.get("type") for action in actions if isinstance(action, dict)}
if "save_process_data" in action_types and "complete_process" in action_types:
self.fail("Found a clause with both 'save_process_data' and 'complete_process' actions")
def test_for_complete_with_step_change(self):
"""Test for app completions with step changes."""
self.register_test_outcome('test_for_complete_with_step_change', 'warning')
triggers = self.json_data.get("triggers", [])
for trigger in triggers:
clauses = trigger.get("clauses", [])
for clause in clauses:
actions = clause.get("actions", [])
action_types = {action.get("type") for action in actions if isinstance(action, dict)}
if "complete_then_change_process_and_step" in action_types:
self.fail("Found a clause with App Completion with Step Change.")
if __name__ == '__main__':
unittest.main(argv=[sys.argv[0]])
gui.py
# gui.py
import tkinter as tk
from tkinter import filedialog, scrolledtext, ttk
import sys
from io import StringIO
import unittest
import zipfile
import json
import apptest # Import the test module
class RedirectText:
"""Redirect stdout to a text widget."""
def __init__(self, text_widget):
self.text_widget = text_widget
def write(self, string):
self.text_widget.insert(tk.END, string)
self.text_widget.see(tk.END)
def flush(self):
pass
class GUIApp:
def __init__(self, root):
self.root = root
self.root.title("Tulip App Unit Tester")
self.root.geometry("800x600")
# Top frame for file chooser and run button
top_frame = ttk.Frame(self.root)
top_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)
# File chooser button and entry
self.file_path_var = tk.StringVar()
ttk.Button(top_frame, text="Choose Zip File", command=self.choose_file).pack(side=tk.LEFT, padx=5)
self.file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, width=100)
self.file_entry.pack(side=tk.LEFT, padx=5)
# Run button
ttk.Button(top_frame, text="Run", command=self.run_tests).pack(side=tk.RIGHT, padx=5)
# Console output text box
self.output_text = scrolledtext.ScrolledText(self.root, height=25, width=90)
self.output_text.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
# Bottom frame for test results
bottom_frame = ttk.Frame(self.root)
bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=5)
# Test result labels
self.passed_var = tk.StringVar(value="Passed: 0")
self.error_var = tk.StringVar(value="Error Fails: 0")
self.warning_var = tk.StringVar(value="Warning Fails: 0")
ttk.Label(bottom_frame, textvariable=self.passed_var).pack(side=tk.LEFT, padx=5)
ttk.Label(bottom_frame, textvariable=self.error_var).pack(side=tk.LEFT, padx=5)
ttk.Label(bottom_frame, textvariable=self.warning_var).pack(side=tk.LEFT, padx=5)
def choose_file(self):
"""Open file dialog to choose a zip file."""
file_path = filedialog.askopenfilename(filetypes=[("Zip files", "*.zip")])
if file_path:
self.file_path_var.set(file_path)
def load_json_from_zip(self, zip_path):
"""Load app.json from the given zip file."""
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
with zip_ref.open('app.json') as json_file:
return json.load(json_file)
def run_tests(self):
"""Run the unit tests from apptest.py and display results."""
self.output_text.delete(1.0, tk.END) # Clear previous output
file_path = self.file_path_var.get()
if not file_path:
self.output_text.insert(tk.END, "Please select a zip file first.\n")
return
# Load JSON from zip file and set it on the test class
try:
apptest.TestAppJson.json_data = self.load_json_from_zip(file_path)
except Exception as e:
self.output_text.insert(tk.END, f"Error loading JSON: {str(e)}\n")
return
# Redirect stdout to the text box
old_stdout = sys.stdout
sys.stdout = RedirectText(self.output_text)
# Run the tests from apptest
suite = unittest.TestLoader().loadTestsFromTestCase(apptest.TestAppJson)
runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=2)
result = runner.run(suite)
# Restore stdout
sys.stdout = old_stdout
# Update test result indicators
total_tests = result.testsRun
passed = total_tests - len(result.failures) - len(result.errors)
# Use test_outcomes to categorize failures
error_fails = len(result.errors) # Unittest exceptions
warning_fails = 0
for test, _ in result.failures:
test_name = test.id().split('.')[-1] # Extract method name
outcome = apptest.TestAppJson.test_outcomes.get(test_name, 'error') # Default to error if not specified
if outcome == 'error':
error_fails += 1
elif outcome == 'warning':
warning_fails += 1
self.passed_var.set(f"Passed: {passed}")
self.error_var.set(f"Error Fails: {error_fails}")
self.warning_var.set(f"Warning Fails: {warning_fails}")
if __name__ == "__main__":
root = tk.Tk()
app = GUIApp(root)
root.mainloop()
