diff --git a/app.py b/app.py index 8e24dc1..47b6113 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ from routes.auth import auth from routes.settings import settings from routes.community import community from routes.shared_env import shared_env +from routes.tests import tests_bp from constants import DEFAULT_FUNCTION_NAME, DEFAULT_SCRIPT, DEFAULT_ENVIRONMENT from flask_apscheduler import APScheduler import asyncio @@ -50,6 +51,7 @@ app.register_blueprint(auth, url_prefix='/auth') app.register_blueprint(settings, url_prefix='/settings') app.register_blueprint(community, url_prefix='/community') app.register_blueprint(shared_env, url_prefix='/shared_env') +app.register_blueprint(tests_bp, url_prefix='/tests') # Swith to inter app routing, which results in speed up from ~400ms to ~270ms # https://stackoverflow.com/questions/76886643/linking-two-not-exposed-dokku-apps diff --git a/db.py b/db.py index a844ce8..d8f5ccd 100644 --- a/db.py +++ b/db.py @@ -640,3 +640,60 @@ ORDER BY invocation_time DESC""", [http_function_id]) [user_id], commit=True ) + + # Function Testing Methods + def create_function_test(self, http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions=None): + """Create a new test case for a function""" + test = self.execute( + '''INSERT INTO http_function_tests + (http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions, created_at, updated_at''', + [http_function_id, name, description, request_method, json.dumps(request_headers), json.dumps(request_body), expected_status, json.dumps(expected_output) if expected_output else None, json.dumps(assertions) if assertions else '[]'], + commit=True, + one=True + ) + return test + + def get_function_tests(self, http_function_id): + """Get all test cases for a function""" + tests = self.execute( + '''SELECT id, http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions, created_at, updated_at + FROM http_function_tests + WHERE http_function_id = %s + ORDER BY created_at DESC''', + [http_function_id] + ) + return tests if tests else [] + + def get_function_test(self, test_id): + """Get a single test case""" + test = self.execute( + '''SELECT id, http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions, created_at, updated_at + FROM http_function_tests + WHERE id = %s''', + [test_id], + one=True + ) + return test + + def update_function_test(self, test_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions=None): + """Update an existing test case""" + test = self.execute( + '''UPDATE http_function_tests + SET name = %s, description = %s, request_method = %s, request_headers = %s, request_body = %s, expected_status = %s, expected_output = %s, assertions = %s, updated_at = NOW() + WHERE id = %s + RETURNING id, http_function_id, name, description, request_method, request_headers, request_body, expected_status, expected_output, assertions, created_at, updated_at''', + [name, description, request_method, json.dumps(request_headers), json.dumps(request_body), expected_status, json.dumps(expected_output) if expected_output else None, json.dumps(assertions) if assertions else '[]', test_id], + commit=True, + one=True + ) + return test + + def delete_function_test(self, test_id): + """Delete a test case""" + self.execute( + 'DELETE FROM http_function_tests WHERE id = %s', + [test_id], + commit=True + ) diff --git a/migrations/007_create_function_tests.sql b/migrations/007_create_function_tests.sql new file mode 100644 index 0000000..1993928 --- /dev/null +++ b/migrations/007_create_function_tests.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS http_function_tests ( + id SERIAL PRIMARY KEY, + http_function_id INTEGER NOT NULL REFERENCES http_functions(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + request_method VARCHAR(10) DEFAULT 'POST', + request_headers JSONB DEFAULT '{}', + request_body JSONB DEFAULT '{}', + expected_status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS', + expected_output JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_http_function_tests_function_id ON http_function_tests(http_function_id); diff --git a/migrations/008_add_test_assertions.sql b/migrations/008_add_test_assertions.sql new file mode 100644 index 0000000..65218ef --- /dev/null +++ b/migrations/008_add_test_assertions.sql @@ -0,0 +1,2 @@ +ALTER TABLE http_function_tests +ADD COLUMN IF NOT EXISTS assertions JSONB DEFAULT '[]'; diff --git a/routes/http.py b/routes/http.py index 76f60ce..65f2334 100644 --- a/routes/http.py +++ b/routes/http.py @@ -456,3 +456,30 @@ def restore(function_id): except Exception as e: print(e) return jsonify({"status": "error", "message": str(e)}), 500 + + +@http.route("/tests/", methods=["GET"]) +@login_required +def tests(function_id): + """Render the tests page for a function""" + user_id = current_user.id + http_function = db.get_http_function_by_id(user_id, function_id) + if not http_function: + return jsonify({"error": "Function not found"}), 404 + + name = http_function["name"] + + if htmx: + return render_block( + environment, + "dashboard/http_functions/tests.html", + "page", + function_id=function_id, + name=name + ) + + return render_template( + "dashboard/http_functions/tests.html", + function_id=function_id, + name=name + ) diff --git a/routes/tests.py b/routes/tests.py new file mode 100644 index 0000000..83e6e32 --- /dev/null +++ b/routes/tests.py @@ -0,0 +1,324 @@ +from flask import Blueprint, request, jsonify +from flask_login import current_user, login_required +from extensions import db +import json + +tests_bp = Blueprint('tests', __name__) + +@tests_bp.route('/http/tests/', methods=['GET']) +@login_required +def list_tests(function_id): + """Get all test cases for a function""" + user_id = current_user.id + + # Verify function ownership + http_function = db.get_http_function_by_id(user_id, function_id) + if not http_function: + return jsonify({'error': 'Function not found'}), 404 + + tests = db.get_function_tests(function_id) + return jsonify({'tests': tests}) + +@tests_bp.route('/http/tests/', methods=['POST']) +@login_required +def create_test(function_id): + """Create a new test case""" + user_id = current_user.id + + # Verify function ownership + http_function = db.get_http_function_by_id(user_id, function_id) + if not http_function: + return jsonify({'error': 'Function not found'}), 404 + + data = request.json + name = data.get('name') + description = data.get('description', '') + request_method = data.get('request_method', 'POST') + request_headers = data.get('request_headers', {}) + request_body = data.get('request_body', {}) + expected_status = data.get('expected_status', 'SUCCESS') + expected_output = data.get('expected_output') + assertions = data.get('assertions', []) + + if not name: + return jsonify({'error': 'Test name is required'}), 400 + + test = db.create_function_test( + function_id, + name, + description, + request_method, + request_headers, + request_body, + expected_status, + expected_output, + assertions + ) + + return jsonify({'status': 'success', 'test': test}) + +@tests_bp.route('/http/tests/test/', methods=['PUT']) +@login_required +def update_test(test_id): + """Update a test case""" + user_id = current_user.id + + # Get and verify test ownership + test = db.get_function_test(test_id) + if not test: + return jsonify({'error': 'Test not found'}), 404 + + http_function = db.get_http_function_by_id(user_id, test['http_function_id']) + if not http_function: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.json + updated_test = db.update_function_test( + test_id, + data.get('name', test['name']), + data.get('description', test['description']), + data.get('request_method', test['request_method']), + data.get('request_headers', test['request_headers']), + data.get('request_body', test['request_body']), + data.get('expected_status', test['expected_status']), + data.get('expected_output', test['expected_output']), + data.get('assertions', test.get('assertions', [])) + ) + + return jsonify({'status': 'success', 'test': updated_test}) + +@tests_bp.route('/http/tests/test/', methods=['DELETE']) +@login_required +def delete_test(test_id): + """Delete a test case""" + user_id = current_user.id + + # Get and verify test ownership + test = db.get_function_test(test_id) + if not test: + return jsonify({'error': 'Test not found'}), 404 + + http_function = db.get_http_function_by_id(user_id, test['http_function_id']) + if not http_function: + return jsonify({'error': 'Unauthorized'}), 403 + + db.delete_function_test(test_id) + return jsonify({'status': 'success', 'message': 'Test deleted'}) + +@tests_bp.route('/http/tests/run/', methods=['POST']) +@login_required +def run_single_test(test_id): + """Run a single test case""" + user_id = current_user.id + + # Get and verify test ownership + test = db.get_function_test(test_id) + if not test: + return jsonify({'error': 'Test not found'}), 404 + + http_function = db.get_http_function_by_id(user_id, test['http_function_id']) + if not http_function: + return jsonify({'error': 'Unauthorized'}), 403 + + # Execute the function with test input + result = execute_test(http_function, test) + return jsonify(result) + +@tests_bp.route('/http/tests/run-all/', methods=['POST']) +@login_required +def run_all_tests(function_id): + """Run all tests for a function""" + user_id = current_user.id + + # Verify function ownership + http_function = db.get_http_function_by_id(user_id, function_id) + if not http_function: + return jsonify({'error': 'Function not found'}), 404 + + tests = db.get_function_tests(function_id) + results = [] + + for test in tests: + result = execute_test(http_function, test) + results.append({ + 'test_id': test['id'], + 'test_name': test['name'], + **result + }) + + passed_count = sum(1 for r in results if r['passed']) + total_count = len(results) + + return jsonify({ + 'results': results, + 'summary': { + 'total': total_count, + 'passed': passed_count, + 'failed': total_count - passed_count + } + }) + + +def get_nested_value(data, path): + """Get value from nested dict using dot notation (e.g., 'user.email')""" + if not path: + return data + + keys = path.split('.') + value = data + try: + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return value + except (KeyError, TypeError): + return None + +def evaluate_assertion(assertion, response_data): + """Evaluate a single assertion against response data""" + assertion_type = assertion.get('type') + path = assertion.get('path', '') + description = assertion.get('description', '') + + # Get value at path + value = get_nested_value(response_data, path) + + result = { + 'type': assertion_type, + 'description': description, + 'path': path, + 'passed': False, + 'message': '' + } + + if assertion_type == 'field_exists': + result['passed'] = value is not None + result['message'] = f"Field '{path}' {'exists' if result['passed'] else 'does not exist'}" + + elif assertion_type == 'field_equals': + expected_value = assertion.get('value') + result['passed'] = value == expected_value + result['expected'] = expected_value + result['actual'] = value + result['message'] = f"Expected {expected_value} but got {value}" if not result['passed'] else f"Field '{path}' equals {expected_value}" + + elif assertion_type == 'field_type': + expected_type = assertion.get('expectedType') + actual_type = type(value).__name__ if value is not None else 'None' + result['passed'] = actual_type == expected_type + result['expected'] = expected_type + result['actual'] = actual_type + result['message'] = f"Expected type {expected_type} but got {actual_type}" if not result['passed'] else f"Field '{path}' is {expected_type}" + + elif assertion_type == 'field_contains': + substring = assertion.get('substring', '') + if isinstance(value, str): + result['passed'] = substring in value + result['message'] = f"Field '{path}' {'contains' if result['passed'] else 'does not contain'} '{substring}'" + else: + result['passed'] = False + result['message'] = f"Field '{path}' is not a string" + + return result + +def execute_test(http_function, test): + """Execute a test case and return the result""" + import requests + from app import app + + try: + # Prepare request data + request_data = { + 'code': http_function['script_content'], + 'environment': http_function['environment_info'] if isinstance(http_function['environment_info'], dict) else json.loads(http_function['environment_info']), + 'request': { + 'method': test['request_method'], + 'headers': test['request_headers'] if isinstance(test['request_headers'], dict) else json.loads(test['request_headers']), + 'json': test['request_body'] if isinstance(test['request_body'], dict) else json.loads(test['request_body']), + 'url': f'/test/{http_function["name"]}', + 'path': '/' + }, + 'name': http_function['name'] + } + + # Call the appropriate runtime isolator + runtime = http_function.get('runtime', 'node') + if runtime == 'python': + api_url = 'http://python-isolator.web:5000/execute' + elif runtime == 'deno': + api_url = 'http://deno-isolator.web:5000/execute' + else: + api_url = 'http://isolator.web:5000/execute' + + response = requests.post(api_url, json=request_data, timeout=30) + response_data = response.json() + + # Get actual results + actual_status = response_data.get('status') + actual_output = response_data.get('result') + expected_status = test['expected_status'] + + # Get assertions + assertions = test.get('assertions') + if isinstance(assertions, str): + assertions = json.loads(assertions) if assertions else [] + elif assertions is None: + assertions = [] + + # If assertions exist, use assertion-based validation + if assertions and len(assertions) > 0: + assertion_results = [] + for idx, assertion in enumerate(assertions): + assertion_result = evaluate_assertion(assertion, actual_output) + assertion_result['id'] = idx + assertion_results.append(assertion_result) + + # Test passes if all assertions pass + passed = all(a['passed'] for a in assertion_results) + + return { + 'passed': passed, + 'actual_status': actual_status, + 'actual_output': actual_output, + 'expected_status': expected_status, + 'assertions': assertion_results, + 'logs': response_data.get('logs', []), + 'error': None + } + + # Fallback to old expected_output comparison + else: + expected_output = test['expected_output'] if isinstance(test['expected_output'], dict) else (json.loads(test['expected_output']) if test['expected_output'] else None) + + # Check if test passed + status_match = actual_status == expected_status + output_match = True + + # If expected output is specified, compare it + if expected_output is not None: + output_match = actual_output == expected_output + + passed = status_match and output_match + + return { + 'passed': passed, + 'actual_status': actual_status, + 'actual_output': actual_output, + 'expected_status': expected_status, + 'expected_output': expected_output, + 'logs': response_data.get('logs', []), + 'error': None + } + + except Exception as e: + return { + 'passed': False, + 'actual_status': 'ERROR', + 'actual_output': None, + 'expected_status': test['expected_status'], + 'expected_output': test.get('expected_output'), + 'logs': [], + 'error': str(e) + } diff --git a/static/js/mithril/TestRunner.js b/static/js/mithril/TestRunner.js new file mode 100644 index 0000000..522a5af --- /dev/null +++ b/static/js/mithril/TestRunner.js @@ -0,0 +1,430 @@ +const TestRunner = { + oninit(vnode) { + this.function_id = vnode.attrs.function_id; + this.function_name = vnode.attrs.function_name; + this.tests = []; + this.testResults = {}; + this.loading = true; + this.runningTests = false; + this.showCreateModal = false; + this.selectedTestForEdit = null; + + // New test form data + this.newTest = { + name: '', + description: '', + request_method: 'POST', + request_headers: '{}', + request_body: '{}', + expected_status: 'SUCCESS', + expected_output: '{}', + assertions: [] + }; + + // Load tests + this.loadTests(); + }, + + async loadTests() { + this.loading = true; + try { + const response = await m.request({ + method: 'GET', + url: `/tests/http/tests/${this.function_id}` + }); + this.tests = response.tests || []; + } catch (err) { + Alert.show('Error loading tests: ' + err.message, 'error'); + } finally { + this.loading = false; + m.redraw(); + } + }, + + async createTest() { + try { + const response = await m.request({ + method: 'POST', + url: `/tests/http/tests/${this.function_id}`, + body: { + name: this.newTest.name, + description: this.newTest.description, + request_method: this.newTest.request_method, + request_headers: JSON.parse(this.newTest.request_headers), + request_body: JSON.parse(this.newTest.request_body), + expected_status: this.newTest.expected_status, + expected_output: this.newTest.expected_output ? JSON.parse(this.newTest.expected_output) : null, + assertions: this.newTest.assertions + } + }); + + Alert.show('Test created successfully', 'success'); + this.showCreateModal = false; + this.resetNewTestForm(); + await this.loadTests(); + } catch (err) { + Alert.show('Error creating test: ' + err.message, 'error'); + } + }, + + async deleteTest(testId) { + if (!confirm('Are you sure you want to delete this test?')) return; + + try { + await m.request({ + method: 'DELETE', + url: `/tests/http/tests/test/${testId}` + }); + + Alert.show('Test deleted', 'success'); + await this.loadTests(); + } catch (err) { + Alert.show('Error deleting test: ' + err.message, 'error'); + } + }, + + async runSingleTest(testId) { + try { + this.runningTests = true; + m.redraw(); + + const result = await m.request({ + method: 'POST', + url: `/tests/http/tests/run/${testId}` + }); + + this.testResults[testId] = result; + m.redraw(); + } catch (err) { + Alert.show('Error running test: ' + err.message, 'error'); + } finally { + this.runningTests = false; + m.redraw(); + } + }, + + async runAllTests() { + try { + this.runningTests = true; + this.testResults = {}; + m.redraw(); + + const response = await m.request({ + method: 'POST', + url: `/tests/http/tests/run-all/${this.function_id}` + }); + + // Map results by test_id + response.results.forEach(result => { + this.testResults[result.test_id] = result; + }); + + const summary = response.summary; + Alert.show(`Tests completed: ${summary.passed}/${summary.total} passed`, summary.passed === summary.total ? 'success' : 'error'); + } catch (err) { + Alert.show('Error running tests: ' + err.message, 'error'); + } finally { + this.runningTests = false; + m.redraw(); + } + }, + + resetNewTestForm() { + this.newTest = { + name: '', + description: '', + request_method: 'POST', + request_headers: '{}', + request_body: '{}', + expected_status: 'SUCCESS', + expected_output: '{}', + assertions: [] + }; + }, + + addAssertion() { + this.newTest.assertions.push({ + type: 'field_exists', + path: '', + description: '' + }); + }, + + removeAssertion(index) { + this.newTest.assertions.splice(index, 1); + }, + + view(vnode) { + const passedCount = Object.values(this.testResults).filter(r => r.passed).length; + const totalTests = this.tests.length; + const hasResults = Object.keys(this.testResults).length > 0; + + return m('div.p-6', [ + // Header + m('div.flex.justify-between.items-center.mb-6', [ + m('div', [ + m('h2.text-2xl.font-bold.text-gray-900.dark:text-white', `Tests for ${this.function_name}`), + hasResults && m('p.text-sm.text-gray-600.dark:text-gray-400.mt-1', + `${passedCount}/${Object.keys(this.testResults).length} tests passed` + ) + ]), + m('div.flex.gap-2', [ + m('button.px-4.py-2.bg-blue-500.text-white.rounded-lg.hover:bg-blue-600.disabled:opacity-50', { + onclick: () => this.showCreateModal = true + }, '+ New Test'), + m('button.px-4.py-2.bg-green-500.text-white.rounded-lg.hover:bg-green-600.disabled:opacity-50', { + onclick: () => this.runAllTests(), + disabled: this.runningTests || this.tests.length === 0 + }, this.runningTests ? 'Running...' : '▶ Run All Tests') + ]) + ]), + + // Tests List + this.loading + ? m('div.text-center.py-12', m('div.animate-spin.h-8.w-8.border-4.border-blue-500.border-t-transparent.rounded-full.mx-auto')) + : this.tests.length === 0 + ? m('div.text-center.py-12', [ + m('p.text-gray-600.dark:text-gray-400', 'No tests yet'), + m('p.text-sm.text-gray-500.dark:text-gray-500.mt-2', 'Create your first test to ensure your function works as expected') + ]) + : m('div.space-y-3', this.tests.map(test => { + const result = this.testResults[test.id]; + const isPassed = result?.passed === true; + const isFailed = result?.passed === false; + + return m('div.bg-white.dark:bg-gray-800.border.rounded-lg.p-4', { + class: isPassed ? 'border-green-500' : isFailed ? 'border-red-500' : 'border-gray-200 dark:border-gray-700' + }, [ + m('div.flex.justify-between.items-start', [ + m('div.flex-1', [ + m('div.flex.items-center.gap-2', [ + result && m('span.text-2xl', isPassed ? '✓' : '✗'), + m('h3.text-lg.font-semibold.text-gray-900.dark:text-white', test.name), + m('span.text-xs.px-2.py-1.rounded.bg-gray-100.dark:bg-gray-700', test.request_method) + ]), + test.description && m('p.text-sm.text-gray-600.dark:text-gray-400.mt-1', test.description), + + // Test Results + result && m('div.mt-3.space-y-2', [ + // Show assertion results if they exist + result.assertions && result.assertions.length > 0 ? [ + m('div.text-sm.font-medium.mb-2', `Assertions: ${result.assertions.filter(a => a.passed).length}/${result.assertions.length} passed`), + result.assertions.map(assertion => + m('div.text-xs.p-2.rounded.border', { + class: assertion.passed + ? 'bg-green-50.dark:bg-green-900/20.border-green-200.dark:border-green-800' + : 'bg-red-50.dark:bg-red-900/20.border-red-200.dark:border-red-800' + }, [ + m('div.flex.items-center.gap-2', [ + m('span', assertion.passed ? '✓' : '✗'), + m('span.font-medium', assertion.description || `${assertion.type}: ${assertion.path}`) + ]), + m('p.text-gray-600.dark:text-gray-400.mt-1', assertion.message) + ]) + ) + ] : [ + // Fallback to old output comparison + isFailed && m('div.bg-red-50.dark:bg-red-900/20.border.border-red-200.dark:border-red-800.rounded.p-3', [ + m('p.text-sm.font-medium.text-red-800.dark:text-red-300', 'Test Failed'), + result.error && m('p.text-xs.text-red-600.dark:text-red-400.mt-1', `Error: ${result.error}`), + result.actual_output !== result.expected_output && m('div.mt-2', [ + m('div.grid.grid-cols-2.gap-2.text-xs', [ + m('div', [ + m('p.font-medium.text-gray-700.dark:text-gray-300', 'Expected:'), + m('pre.bg-white.dark:bg-gray-900.p-2.rounded.mt-1.overflow-auto', + JSON.stringify(result.expected_output, null, 2)) + ]), + m('div', [ + m('p.font-medium.text-gray-700.dark:text-gray-300', 'Actual:'), + m('pre.bg-white.dark:bg-gray-900.p-2.rounded.mt-1.overflow-auto', + JSON.stringify(result.actual_output, null, 2)) + ]) + ]) + ]) + ]), + isPassed && m('div.bg-green-50.dark:bg-green-900/20.border.border-green-200.dark:border-green-800.rounded.p-3', [ + m('p.text-sm.font-medium.text-green-800.dark:text-green-300', '✓ Test Passed') + ]) + ] + ]) + ]), + m('div.flex.gap-2', [ + m('button.px-3.py-1.text-sm.bg-blue-500.text-white.rounded.hover:bg-blue-600', { + onclick: () => this.runSingleTest(test.id), + disabled: this.runningTests + }, '▶ Run'), + m('button.px-3.py-1.text-sm.bg-red-500.text-white.rounded.hover:bg-red-600', { + onclick: () => this.deleteTest(test.id) + }, 'Delete') + ]) + ]) + ]); + })), + + // Create Test Modal + this.showCreateModal && m('div.fixed.inset-0.bg-black.bg-opacity-50.flex.items-center.justify-center.z-50', { + onclick: () => this.showCreateModal = false + }, [ + m('div.bg-white.dark:bg-gray-800.rounded-lg.p-6.max-w-2xl.w-full.overflow-y-auto', { + onclick: (e) => e.stopPropagation(), + style: 'max-height: 90vh;' + }, [ + m('h3.text-xl.font-bold.mb-4.text-gray-900.dark:text-white', 'Create New Test'), + m('form', { + onsubmit: (e) => { + e.preventDefault(); + this.createTest(); + } + }, [ + m('div.space-y-4', [ + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Test Name'), + m('input.w-full.p-2.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.name, + oninput: (e) => this.newTest.name = e.target.value, + required: true, + placeholder: 'Valid user creation' + }) + ]), + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Description'), + m('textarea.w-full.p-2.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.description, + oninput: (e) => this.newTest.description = e.target.value, + rows: 2 + }) + ]), + m('div.grid.grid-cols-2.gap-4', [ + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Method'), + m('select.w-full.p-2.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.request_method, + onchange: (e) => this.newTest.request_method = e.target.value + }, [ + m('option', {value: 'GET'}, 'GET'), + m('option', {value: 'POST'}, 'POST'), + m('option', {value: 'PUT'}, 'PUT'), + m('option', {value: 'DELETE'}, 'DELETE') + ]) + ]), + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Expected Status'), + m('select.w-full.p-2.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.expected_status, + onchange: (e) => this.newTest.expected_status = e.target.value + }, [ + m('option', {value: 'SUCCESS'}, 'SUCCESS'), + m('option', {value: 'ERROR'}, 'ERROR') + ]) + ]) + ]), + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Request Headers (JSON)'), + m('textarea.w-full.p-2.border.rounded.font-mono.text-sm.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.request_headers, + oninput: (e) => this.newTest.request_headers = e.target.value, + rows: 3 + }) + ]), + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Request Body (JSON)'), + m('textarea.w-full.p-2.border.rounded.font-mono.text-sm.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.request_body, + oninput: (e) => this.newTest.request_body = e.target.value, + rows: 4 + }) + ]), + m('div', [ + m('label.block.text-sm.font-medium.mb-1', 'Expected Output (JSON)'), + m('textarea.w-full.p-2.border.rounded.font-mono.text-sm.dark:bg-gray-700.dark:border-gray-600', { + value: this.newTest.expected_output, + oninput: (e) => this.newTest.expected_output = e.target.value, + rows: 4 + }) + ]), + // Assertions Section + m('div.border-t.pt-4.mt-4', [ + m('div.flex.justify-between.items-center.mb-3', [ + m('label.block.text-sm.font-medium', 'Assertions (Advanced)'), + m('button.text-sm.px-3.py-1.bg-green-500.text-white.rounded.hover:bg-green-600', { + type: 'button', + onclick: () => this.addAssertion() + }, '+ Add Assertion') + ]), + this.newTest.assertions.length === 0 + ? m('p.text-sm.text-gray-500.dark:text-gray-400', 'No assertions yet. Add assertions to validate specific fields in the response.') + : m('div.space-y-3', this.newTest.assertions.map((assertion, idx) => + m('div.p-3.border.rounded.dark:border-gray-600.bg-gray-50.dark:bg-gray-900', [ + m('div.flex.justify-between.items-start.mb-2', [ + m('span.text-xs.font-medium.text-gray-700.dark:text-gray-300', `Assertion ${idx + 1}`), + m('button.text-red-500.hover:text-red-700.text-xs', { + type: 'button', + onclick: () => this.removeAssertion(idx) + }, '✕ Remove') + ]), + m('div.grid.grid-cols-2.gap-2', [ + m('div', [ + m('label.block.text-xs.mb-1', 'Type'), + m('select.w-full.p-1.text-sm.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: assertion.type, + onchange: (e) => assertion.type = e.target.value + }, [ + m('option', {value: 'field_exists'}, 'Field Exists'), + m('option', {value: 'field_equals'}, 'Field Equals'), + m('option', {value: 'field_type'}, 'Field Type'), + m('option', {value: 'field_contains'}, 'Field Contains') + ]) + ]), + m('div', [ + m('label.block.text-xs.mb-1', 'Path'), + m('input.w-full.p-1.text-sm.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: assertion.path, + oninput: (e) => assertion.path = e.target.value, + placeholder: 'e.g., user.email' + }) + ]) + ]), + // Conditional fields based on assertion type + (assertion.type === 'field_equals' || assertion.type === 'field_type' || assertion.type === 'field_contains') && m('div.mt-2', [ + m('label.block.text-xs.mb-1', + assertion.type === 'field_equals' ? 'Expected Value' : + assertion.type === 'field_type' ? 'Expected Type' : 'Substring'), + m('input.w-full.p-1.text-sm.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: assertion.type === 'field_equals' ? assertion.value : + assertion.type === 'field_type' ? assertion.expectedType : + assertion.substring, + oninput: (e) => { + if (assertion.type === 'field_equals') assertion.value = e.target.value; + else if (assertion.type === 'field_type') assertion.expectedType = e.target.value; + else assertion.substring = e.target.value; + }, + placeholder: assertion.type === 'field_type' ? 'str, int, bool, etc.' : '' + }) + ]), + m('div.mt-2', [ + m('label.block.text-xs.mb-1', 'Description (Optional)'), + m('input.w-full.p-1.text-sm.border.rounded.dark:bg-gray-700.dark:border-gray-600', { + value: assertion.description, + oninput: (e) => assertion.description = e.target.value, + placeholder: 'Describe this check' + }) + ]) + ]) + )) + ]) + ]), + m('div.flex.justify-end.gap-2.mt-6', [ + m('button.px-4.py-2.border.rounded.hover:bg-gray-100.dark:hover:bg-gray-700', { + type: 'button', + onclick: () => this.showCreateModal = false + }, 'Cancel'), + m('button.px-4.py-2.bg-blue-500.text-white.rounded.hover:bg-blue-600', { + type: 'submit' + }, 'Create Test') + ]) + ]) + ]) + ]) + ]); + } +}; diff --git a/templates/base.html b/templates/base.html index 4808b81..718590f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -37,6 +37,7 @@ + diff --git a/templates/dashboard/http_functions/client.html b/templates/dashboard/http_functions/client.html index 808f3a9..854b76f 100644 --- a/templates/dashboard/http_functions/client.html +++ b/templates/dashboard/http_functions/client.html @@ -8,10 +8,12 @@ show_edit_form=True, show_logs=True, show_client=True, show_history=True, +show_tests=True, edit_url=url_for('http.editor', function_id=function_id), cancel_url=url_for('http.overview'), logs_url=url_for('http.logs', function_id=function_id), -history_url=url_for('http.history', function_id=function_id)) }} +history_url=url_for('http.history', function_id=function_id), +tests_url=url_for('http.tests', function_id=function_id)) }}
diff --git a/templates/dashboard/http_functions/editor.html b/templates/dashboard/http_functions/editor.html index d708739..73079bc 100644 --- a/templates/dashboard/http_functions/editor.html +++ b/templates/dashboard/http_functions/editor.html @@ -8,10 +8,12 @@ show_edit_form=True, show_logs=True, show_client=True, show_history=True, +show_tests=True, edit_url=edit_url, cancel_url=cancel_url, logs_url=url_for('http.logs', function_id=function_id), -history_url=url_for('http.history', function_id=function_id)) }} +history_url=url_for('http.history', function_id=function_id), +tests_url=url_for('http.tests', function_id=function_id)) }}
diff --git a/templates/dashboard/http_functions/header.html b/templates/dashboard/http_functions/header.html index 40a4fa3..8eed956 100644 --- a/templates/dashboard/http_functions/header.html +++ b/templates/dashboard/http_functions/header.html @@ -1,99 +1,113 @@
- -
- -
- -
+
+
+
\ No newline at end of file diff --git a/templates/dashboard/http_functions/history.html b/templates/dashboard/http_functions/history.html index d7322ae..0418568 100644 --- a/templates/dashboard/http_functions/history.html +++ b/templates/dashboard/http_functions/history.html @@ -8,10 +8,12 @@ show_edit_form=True, show_logs=True, show_client=True, show_history=True, +show_tests=True, edit_url=url_for('http.editor', function_id=function_id), cancel_url=url_for('http.overview'), logs_url=url_for('http.logs', function_id=function_id), -history_url=url_for('http.history', function_id=function_id)) }} +history_url=url_for('http.history', function_id=function_id), +tests_url=url_for('http.tests', function_id=function_id)) }}
diff --git a/templates/dashboard/http_functions/logs.html b/templates/dashboard/http_functions/logs.html index 623b908..ece69e0 100644 --- a/templates/dashboard/http_functions/logs.html +++ b/templates/dashboard/http_functions/logs.html @@ -8,10 +8,12 @@ show_edit_form=True, show_logs=True, show_client=True, show_history=True, +show_tests=True, edit_url=url_for('http.editor', function_id=function_id), cancel_url=url_for('http.overview'), logs_url=url_for('http.logs', function_id=function_id), -history_url=url_for('http.history', function_id=function_id)) }} +history_url=url_for('http.history', function_id=function_id), +tests_url=url_for('http.tests', function_id=function_id)) }}
{{ render_partial('dashboard/analytics.html', invocations=http_function_invocations) }} diff --git a/templates/dashboard/http_functions/tests.html b/templates/dashboard/http_functions/tests.html new file mode 100644 index 0000000..be1ae34 --- /dev/null +++ b/templates/dashboard/http_functions/tests.html @@ -0,0 +1,30 @@ +{% extends 'dashboard.html' %} + +{% block page %} + +{{ render_partial('dashboard/http_functions/header.html', user_id=user_id, function_id=function_id, +active_tab='tests', +show_edit_form=True, +show_logs=True, +show_client=True, +show_history=True, +show_tests=True, +cancel_url=cancel_url, +edit_url=url_for('http.editor', function_id=function_id), +logs_url=url_for('http.logs', function_id=function_id), +history_url=url_for('http.history', function_id=function_id), +tests_url=url_for('http.tests', function_id=function_id)) }} + +
+ + +{% endblock %} \ No newline at end of file