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 Data
andComplete 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()