WIP: Add tests for http functions

This commit is contained in:
Peter Stockings
2025-12-03 21:12:35 +11:00
parent c5eb1ce463
commit 049c875bc2
14 changed files with 979 additions and 69 deletions

2
app.py
View File

@@ -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

57
db.py
View File

@@ -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
)

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_function_tests
ADD COLUMN IF NOT EXISTS assertions JSONB DEFAULT '[]';

View File

@@ -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/<int:function_id>", 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
)

324
routes/tests.py Normal file
View File

@@ -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/<int:function_id>', 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/<int:function_id>', 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/<int:test_id>', 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/<int:test_id>', 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/<int:test_id>', 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/<int:function_id>', 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)
}

View File

@@ -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')
])
])
])
])
]);
}
};

View File

@@ -37,6 +37,7 @@
<script src="/static/js/mithril/alert.js"></script>
<script src="/static/js/mithril/diffView.js"></script>
<script src="/static/js/mithril/FunctionHistory.js"></script>
<script src="{{ url_for('static', filename='js/mithril/TestRunner.js') }}"></script>
<script src="https://unpkg.com/ace-diff@^2"></script>
<link href="https://unpkg.com/ace-diff@^2/dist/ace-diff.min.css" rel="stylesheet">

View File

@@ -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)) }}
<div class="mx-auto w-full pt-4" id="client-u{{ user_id }}-f{{ function_id }}">
</div>

View File

@@ -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)) }}
<div id="app" class="">

View File

@@ -1,99 +1,113 @@
<div class="">
<!-- Tabs and Actions -->
<div class="border-b border-gray-200 dark:border-gray-800 flex justify-between items-end">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
{% if show_edit_form|default(false, true) %}
<a href="#" hx-get="{{ edit_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
<!-- Tabs and Actions -->
<div class="border-b border-gray-200 dark:border-gray-800 flex justify-between items-end">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
{% if show_edit_form|default(false, true) %}
<a href="#" hx-get="{{ edit_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'edit' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
Editor
</a>
{% endif %}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
Editor
</a>
{% endif %}
{% if show_logs|default(false, true) %}
<a href="#" hx-get="{{ logs_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if show_logs|default(false, true) %}
<a href="#" hx-get="{{ logs_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'logs' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
Logs
</a>
{% endif %}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
Logs
</a>
{% endif %}
{% if show_client|default(false, true) %}
<a href="#" hx-get="{{ url_for('http.client', function_id=function_id) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if show_client|default(false, true) %}
<a href="#" hx-get="{{ url_for('http.client', function_id=function_id) }}" hx-target="#container"
hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'client' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
</svg>
Client
</a>
{% endif %}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
</svg>
Client
</a>
{% endif %}
{% if show_history|default(false, true) %}
<a href="#" hx-get="{{ history_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if show_history|default(false, true) %}
<a href="#" hx-get="{{ history_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'history' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0 1 18 0Z" />
</svg>
History
</a>
{% endif %}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0 1 18 0Z" />
</svg>
History
</a>
{% endif %}
{% if show_new|default(false, true) %}
<a href="#" hx-get="{{ new_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if show_tests|default(false, true) %}
<a href="#" hx-get="{{ tests_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'tests' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0 1 18 0z" />
</svg>
Tests
</a>
{% endif %}
{% if show_new|default(false, true) %}
<a href="#" hx-get="{{ new_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2
{% if active_tab == 'new' %}
border-blue-500 text-blue-600 dark:text-blue-400
{% else %}
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300
{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Function
</a>
{% endif %}
</nav>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Function
</a>
{% endif %}
</nav>
<div class="pb-2">
<button hx-get="{{ cancel_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
class="inline-flex items-center justify-center px-3 py-1.5 border border-gray-300 dark:border-gray-700 shadow-sm text-xs font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1.5 text-gray-500 dark:text-gray-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
</button>
</div>
<div class="pb-2">
<button hx-get="{{ cancel_url }}" hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
class="inline-flex items-center justify-center px-3 py-1.5 border border-gray-300 dark:border-gray-700 shadow-sm text-xs font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1.5 text-gray-500 dark:text-gray-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
</button>
</div>
</div>
</div>

View File

@@ -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)) }}
<div id="history-view"></div>

View File

@@ -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)) }}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{{ render_partial('dashboard/analytics.html', invocations=http_function_invocations) }}

View File

@@ -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)) }}
<div id="test-runner"></div>
<script>
m.mount(document.getElementById('test-runner'), {
view: function () {
return m(TestRunner, {
function_id: {{ function_id }},
function_name: '{{ name }}'
});
}
});
</script>
{% endblock %}