soleprint init commit

This commit is contained in:
buenosairesam
2025-12-24 05:38:37 -03:00
commit 329c401ff5
96 changed files with 11564 additions and 0 deletions

View File

@@ -0,0 +1,862 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Filters - Ward</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111827;
color: #e5e7eb;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #374151;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #f9fafb;
}
.nav-links {
display: flex;
gap: 12px;
font-size: 0.875rem;
}
.nav-links a {
color: #60a5fa;
text-decoration: none;
padding: 6px 12px;
border-radius: 4px;
transition: background 0.2s;
}
.nav-links a:hover {
background: #374151;
}
.nav-links a.active {
background: #2563eb;
color: white;
}
/* Filter Panel */
.filter-panel {
background: #1f2937;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.filter-section {
margin-bottom: 20px;
}
.filter-section:last-child {
margin-bottom: 0;
}
.filter-label {
font-weight: 600;
font-size: 0.875rem;
color: #9ca3af;
text-transform: uppercase;
margin-bottom: 10px;
display: block;
}
.filter-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-chip {
padding: 6px 12px;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
background: #374151;
color: #e5e7eb;
border: 2px solid transparent;
}
.filter-chip:hover {
background: #4b5563;
}
.filter-chip.active {
background: #2563eb;
color: white;
border-color: #1d4ed8;
}
.search-box {
width: 100%;
padding: 10px 12px;
background: #374151;
border: 2px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.search-box:focus {
outline: none;
border-color: #2563eb;
}
.search-box::placeholder {
color: #6b7280;
}
/* Test List */
.test-list {
background: #1f2937;
border-radius: 8px;
overflow: hidden;
}
.list-header {
padding: 12px 16px;
background: #374151;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.test-count {
font-size: 0.75rem;
color: #9ca3af;
background: #1f2937;
padding: 4px 10px;
border-radius: 10px;
}
.list-body {
padding: 16px;
max-height: 600px;
overflow-y: auto;
}
.test-card {
background: #374151;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.test-card:hover {
background: #4b5563;
border-color: #2563eb;
}
.test-card.selected {
border-color: #2563eb;
background: #1e3a8a;
}
.test-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 8px;
}
.test-title {
font-weight: 600;
color: #f9fafb;
font-size: 0.95rem;
}
.test-status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-passed {
background: #065f46;
color: #34d399;
}
.status-failed {
background: #7f1d1d;
color: #f87171;
}
.status-skipped {
background: #78350f;
color: #fbbf24;
}
.status-unknown {
background: #374151;
color: #9ca3af;
}
.test-path {
font-size: 0.75rem;
color: #9ca3af;
font-family: monospace;
margin-bottom: 6px;
}
.test-doc {
font-size: 0.875rem;
color: #d1d5db;
line-height: 1.4;
}
.test-meta {
display: flex;
gap: 12px;
margin-top: 8px;
font-size: 0.75rem;
color: #6b7280;
}
.test-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6b7280;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
/* Action Bar */
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #1f2937;
border-radius: 8px;
margin-bottom: 20px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-primary:disabled {
background: #4b5563;
cursor: not-allowed;
}
.btn-secondary {
background: #374151;
color: #e5e7eb;
}
.btn-secondary:hover {
background: #4b5563;
}
.selection-info {
font-size: 0.875rem;
color: #9ca3af;
}
.selection-info strong {
color: #60a5fa;
}
/* Responsive */
@media (max-width: 768px) {
.filter-section {
margin-bottom: 16px;
}
.action-bar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.selection-info {
text-align: center;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Contract HTTP Tests - Filters</h1>
<div class="nav-links">
<a href="/tools/tester/">Runner</a>
<a href="/tools/tester/filters" class="active">Filters</a>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; font-size: 0.875rem; color: #9ca3af;">
<span>Target:</span>
<select id="environmentSelector" style="background: #374151; color: #e5e7eb; border: 1px solid #4b5563; border-radius: 4px; padding: 4px 8px; font-size: 0.875rem; cursor: pointer;">
<option value="">Loading...</option>
</select>
<strong id="currentUrl" style="color: #60a5fa;">Loading...</strong>
</div>
</header>
<div class="filter-panel">
<div class="filter-section">
<label class="filter-label">Search</label>
<input
type="text"
class="search-box"
id="searchInput"
placeholder="Search by test name, class, or description..."
autocomplete="off"
>
</div>
<div class="filter-section">
<label class="filter-label">Domain</label>
<div class="filter-group" id="domainFilters">
<div class="filter-chip active" data-filter="all" onclick="toggleDomainFilter(this)">
All Domains
</div>
</div>
</div>
<div class="filter-section">
<label class="filter-label">Module</label>
<div class="filter-group" id="moduleFilters">
<div class="filter-chip active" data-filter="all" onclick="toggleModuleFilter(this)">
All Modules
</div>
</div>
</div>
<div class="filter-section">
<label class="filter-label">Status (from last run)</label>
<div class="filter-group">
<div class="filter-chip active" data-status="all" onclick="toggleStatusFilter(this)">
All
</div>
<div class="filter-chip" data-status="passed" onclick="toggleStatusFilter(this)">
Passed
</div>
<div class="filter-chip" data-status="failed" onclick="toggleStatusFilter(this)">
Failed
</div>
<div class="filter-chip" data-status="skipped" onclick="toggleStatusFilter(this)">
Skipped
</div>
<div class="filter-chip" data-status="unknown" onclick="toggleStatusFilter(this)">
Not Run
</div>
</div>
</div>
<div class="filter-section">
<button class="btn btn-secondary" onclick="clearFilters()">Clear All Filters</button>
</div>
</div>
<div class="action-bar">
<div class="selection-info">
<span id="selectedCount">0</span> tests selected
</div>
<div style="display: flex; gap: 10px;">
<button class="btn btn-secondary" onclick="selectAll()">Select All Visible</button>
<button class="btn btn-secondary" onclick="deselectAll()">Deselect All</button>
<button class="btn btn-primary" id="runSelectedBtn" onclick="runSelected()">Run Selected</button>
</div>
</div>
<div class="test-list">
<div class="list-header">
<span>Tests</span>
<span class="test-count" id="testCount">Loading...</span>
</div>
<div class="list-body" id="testListBody">
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div>Loading tests...</div>
</div>
</div>
</div>
</div>
<script>
let allTests = [];
let selectedTests = new Set();
let lastRunResults = {};
// Filter state
let filters = {
search: '',
domains: new Set(['all']),
modules: new Set(['all']),
status: new Set(['all'])
};
// Load tests on page load
async function loadTests() {
try {
const response = await fetch('/tools/tester/api/tests');
const data = await response.json();
allTests = data.tests;
// Extract unique domains and modules
const domains = new Set();
const modules = new Set();
allTests.forEach(test => {
const parts = test.id.split('.');
if (parts.length >= 2) {
domains.add(parts[0]);
modules.add(parts[1]);
}
});
// Populate domain filters
const domainFilters = document.getElementById('domainFilters');
domains.forEach(domain => {
const chip = document.createElement('div');
chip.className = 'filter-chip';
chip.dataset.filter = domain;
chip.textContent = domain;
chip.onclick = function() { toggleDomainFilter(this); };
domainFilters.appendChild(chip);
});
// Populate module filters
const moduleFilters = document.getElementById('moduleFilters');
modules.forEach(module => {
const chip = document.createElement('div');
chip.className = 'filter-chip';
chip.dataset.filter = module;
chip.textContent = module.replace('test_', '');
chip.onclick = function() { toggleModuleFilter(this); };
moduleFilters.appendChild(chip);
});
// Try to load last run results
await loadLastRunResults();
renderTests();
} catch (error) {
console.error('Failed to load tests:', error);
document.getElementById('testListBody').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div>Failed to load tests</div>
</div>
`;
}
}
async function loadLastRunResults() {
try {
const response = await fetch('/tools/tester/api/runs');
const data = await response.json();
if (data.runs && data.runs.length > 0) {
const lastRunId = data.runs[0];
const runResponse = await fetch(`/tools/tester/api/run/${lastRunId}`);
const runData = await runResponse.json();
runData.results.forEach(result => {
lastRunResults[result.test_id] = result.status;
});
}
} catch (error) {
console.error('Failed to load last run results:', error);
}
}
function getTestStatus(testId) {
return lastRunResults[testId] || 'unknown';
}
function toggleDomainFilter(chip) {
const filter = chip.dataset.filter;
if (filter === 'all') {
// Deselect all others
document.querySelectorAll('#domainFilters .filter-chip').forEach(c => {
c.classList.remove('active');
});
chip.classList.add('active');
filters.domains = new Set(['all']);
} else {
// Remove 'all'
document.querySelector('#domainFilters [data-filter="all"]').classList.remove('active');
if (filters.domains.has(filter)) {
filters.domains.delete(filter);
chip.classList.remove('active');
} else {
filters.domains.add(filter);
chip.classList.add('active');
}
// If nothing selected, select all
if (filters.domains.size === 0 || filters.domains.has('all')) {
document.querySelector('#domainFilters [data-filter="all"]').classList.add('active');
document.querySelectorAll('#domainFilters .filter-chip:not([data-filter="all"])').forEach(c => {
c.classList.remove('active');
});
filters.domains = new Set(['all']);
}
}
renderTests();
}
function toggleModuleFilter(chip) {
const filter = chip.dataset.filter;
if (filter === 'all') {
document.querySelectorAll('#moduleFilters .filter-chip').forEach(c => {
c.classList.remove('active');
});
chip.classList.add('active');
filters.modules = new Set(['all']);
} else {
document.querySelector('#moduleFilters [data-filter="all"]').classList.remove('active');
if (filters.modules.has(filter)) {
filters.modules.delete(filter);
chip.classList.remove('active');
} else {
filters.modules.add(filter);
chip.classList.add('active');
}
if (filters.modules.size === 0 || filters.modules.has('all')) {
document.querySelector('#moduleFilters [data-filter="all"]').classList.add('active');
document.querySelectorAll('#moduleFilters .filter-chip:not([data-filter="all"])').forEach(c => {
c.classList.remove('active');
});
filters.modules = new Set(['all']);
}
}
renderTests();
}
function toggleStatusFilter(chip) {
const status = chip.dataset.status;
if (status === 'all') {
document.querySelectorAll('[data-status]').forEach(c => {
c.classList.remove('active');
});
chip.classList.add('active');
filters.status = new Set(['all']);
} else {
document.querySelector('[data-status="all"]').classList.remove('active');
if (filters.status.has(status)) {
filters.status.delete(status);
chip.classList.remove('active');
} else {
filters.status.add(status);
chip.classList.add('active');
}
if (filters.status.size === 0 || filters.status.has('all')) {
document.querySelector('[data-status="all"]').classList.add('active');
document.querySelectorAll('[data-status]:not([data-status="all"])').forEach(c => {
c.classList.remove('active');
});
filters.status = new Set(['all']);
}
}
renderTests();
}
function clearFilters() {
// Reset search
document.getElementById('searchInput').value = '';
filters.search = '';
// Reset domains
document.querySelectorAll('#domainFilters .filter-chip').forEach(c => c.classList.remove('active'));
document.querySelector('#domainFilters [data-filter="all"]').classList.add('active');
filters.domains = new Set(['all']);
// Reset modules
document.querySelectorAll('#moduleFilters .filter-chip').forEach(c => c.classList.remove('active'));
document.querySelector('#moduleFilters [data-filter="all"]').classList.add('active');
filters.modules = new Set(['all']);
// Reset status
document.querySelectorAll('[data-status]').forEach(c => c.classList.remove('active'));
document.querySelector('[data-status="all"]').classList.add('active');
filters.status = new Set(['all']);
renderTests();
}
function filterTests() {
return allTests.filter(test => {
const parts = test.id.split('.');
const domain = parts[0];
const module = parts[1];
const status = getTestStatus(test.id);
// Search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
const matchesSearch =
test.name.toLowerCase().includes(searchLower) ||
test.class_name.toLowerCase().includes(searchLower) ||
(test.doc && test.doc.toLowerCase().includes(searchLower)) ||
test.id.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
// Domain filter
if (!filters.domains.has('all') && !filters.domains.has(domain)) {
return false;
}
// Module filter
if (!filters.modules.has('all') && !filters.modules.has(module)) {
return false;
}
// Status filter
if (!filters.status.has('all') && !filters.status.has(status)) {
return false;
}
return true;
});
}
function renderTests() {
const filteredTests = filterTests();
const container = document.getElementById('testListBody');
document.getElementById('testCount').textContent = `${filteredTests.length} of ${allTests.length}`;
if (filteredTests.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div>No tests match your filters</div>
</div>
`;
return;
}
container.innerHTML = filteredTests.map(test => {
const status = getTestStatus(test.id);
const isSelected = selectedTests.has(test.id);
const parts = test.id.split('.');
const domain = parts[0];
const module = parts[1];
return `
<div class="test-card ${isSelected ? 'selected' : ''}" onclick="toggleTestSelection('${test.id}')" data-test-id="${test.id}">
<div class="test-header">
<div class="test-title">${formatTestName(test.method_name)}</div>
<div class="test-status-badge status-${status}">${status}</div>
</div>
<div class="test-path">${test.id}</div>
<div class="test-doc">${test.doc || 'No description'}</div>
<div class="test-meta">
<span>📁 ${domain}</span>
<span>📄 ${module}</span>
<span>🏷️ ${test.class_name}</span>
</div>
</div>
`;
}).join('');
updateSelectionInfo();
}
function formatTestName(name) {
return name.replace(/^test_/, '').replace(/_/g, ' ');
}
function toggleTestSelection(testId) {
if (selectedTests.has(testId)) {
selectedTests.delete(testId);
} else {
selectedTests.add(testId);
}
// Update UI
const card = document.querySelector(`[data-test-id="${testId}"]`);
if (card) {
card.classList.toggle('selected');
}
updateSelectionInfo();
}
function selectAll() {
const filteredTests = filterTests();
filteredTests.forEach(test => selectedTests.add(test.id));
renderTests();
}
function deselectAll() {
selectedTests.clear();
renderTests();
}
function updateSelectionInfo() {
document.getElementById('selectedCount').textContent = selectedTests.size;
document.getElementById('runSelectedBtn').disabled = selectedTests.size === 0;
}
async function runSelected() {
if (selectedTests.size === 0) {
alert('No tests selected');
return;
}
const testIds = Array.from(selectedTests);
try {
const response = await fetch('/tools/tester/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_ids: testIds }),
});
const data = await response.json();
// Build URL params to preserve filter state in runner
const params = new URLSearchParams();
params.set('run', data.run_id);
// Pass filter state
if (filters.search) params.set('search', filters.search);
if (!filters.domains.has('all')) {
params.set('domains', Array.from(filters.domains).join(','));
}
if (!filters.modules.has('all')) {
params.set('modules', Array.from(filters.modules).join(','));
}
if (!filters.status.has('all')) {
params.set('status', Array.from(filters.status).join(','));
}
// Redirect to main runner with filters applied
window.location.href = `/tools/tester/?${params.toString()}`;
} catch (error) {
console.error('Failed to start run:', error);
alert('Failed to start test run');
}
}
// Search input handler
document.getElementById('searchInput').addEventListener('input', (e) => {
filters.search = e.target.value;
renderTests();
});
// Load environments
async function loadEnvironments() {
try {
const response = await fetch('/tools/tester/api/environments');
const data = await response.json();
const selector = document.getElementById('environmentSelector');
const currentUrl = document.getElementById('currentUrl');
const savedEnvId = localStorage.getItem('selectedEnvironment');
let selectedEnv = null;
selector.innerHTML = data.environments.map(env => {
const isDefault = env.default || env.id === savedEnvId;
if (isDefault) selectedEnv = env;
return `<option value="${env.id}" ${isDefault ? 'selected' : ''}>${env.name} ${env.has_api_key ? '🔑' : ''}</option>`;
}).join('');
if (selectedEnv) {
currentUrl.textContent = selectedEnv.url;
}
selector.addEventListener('change', async (e) => {
const envId = e.target.value;
try {
const response = await fetch(`/tools/tester/api/environment/select?env_id=${envId}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
currentUrl.textContent = data.environment.url;
localStorage.setItem('selectedEnvironment', envId);
}
} catch (error) {
console.error('Failed to switch environment:', error);
alert('Failed to switch environment');
}
});
} catch (error) {
console.error('Failed to load environments:', error);
}
}
// Load tests on page load
loadEnvironments();
loadTests();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,909 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contract Tests - Ward</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111827;
color: #e5e7eb;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #374151;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #f9fafb;
}
.config-info {
font-size: 0.875rem;
color: #9ca3af;
}
.config-info strong {
color: #60a5fa;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
button {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-primary:disabled {
background: #4b5563;
cursor: not-allowed;
}
.btn-secondary {
background: #374151;
color: #e5e7eb;
}
.btn-secondary:hover {
background: #4b5563;
}
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
}
}
.panel {
background: #1f2937;
border-radius: 8px;
overflow: hidden;
}
.panel-header {
padding: 12px 16px;
background: #374151;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-body {
padding: 16px;
max-height: 600px;
overflow-y: auto;
}
/* Test Tree */
.folder {
margin-bottom: 8px;
}
.folder-header {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.folder-header:hover {
background: #374151;
}
.folder-header input {
margin-right: 12px;
}
.folder-name {
font-weight: 500;
color: #f9fafb;
}
.test-count {
margin-left: auto;
font-size: 0.75rem;
color: #9ca3af;
background: #374151;
padding: 2px 8px;
border-radius: 10px;
}
.folder-content {
margin-left: 20px;
}
.module {
margin: 4px 0;
}
.module-header {
display: flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
}
.module-header:hover {
background: #374151;
}
.module-header input {
margin-right: 12px;
}
.module-name {
color: #93c5fd;
font-size: 1rem;
}
.class-block {
margin-left: 20px;
}
.class-header {
display: flex;
align-items: center;
padding: 4px 8px;
font-size: 1rem;
color: #a78bfa;
cursor: pointer;
}
.class-header:hover {
background: #374151;
border-radius: 4px;
}
.class-header input {
margin-right: 12px;
}
.test-list {
margin-left: 20px;
}
.test-item {
display: flex;
align-items: center;
padding: 6px 8px;
font-size: 0.95rem;
border-radius: 4px;
}
.test-item:hover {
background: #374151;
}
.test-item input {
margin-right: 12px;
}
.test-name {
color: #d1d5db;
}
/* Results */
.summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
}
.stat-passed .stat-value { color: #34d399; }
.stat-failed .stat-value { color: #f87171; }
.stat-skipped .stat-value { color: #fbbf24; }
.stat-running .stat-value { color: #60a5fa; }
.result-item {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
background: #374151;
display: flex;
align-items: center;
gap: 8px;
}
.result-icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
flex-shrink: 0;
}
.result-passed .result-icon {
background: #065f46;
color: #34d399;
}
.result-failed .result-icon,
.result-error .result-icon {
background: #7f1d1d;
color: #f87171;
}
.result-skipped .result-icon {
background: #78350f;
color: #fbbf24;
}
.result-running .result-icon {
background: #1e3a8a;
color: #60a5fa;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-test-id {
font-size: 0.75rem;
color: #6b7280;
}
.result-duration {
font-size: 0.75rem;
color: #9ca3af;
}
.result-error {
margin-top: 8px;
padding: 8px;
background: #1f2937;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
white-space: pre-wrap;
color: #f87171;
max-height: 200px;
overflow-y: auto;
}
.empty-state {
text-align: center;
padding: 40px;
color: #6b7280;
}
.progress-bar {
height: 4px;
background: #374151;
border-radius: 2px;
margin-bottom: 16px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #2563eb;
transition: width 0.3s;
}
.current-test {
font-size: 0.75rem;
color: #60a5fa;
margin-bottom: 8px;
font-style: italic;
}
/* Collapsible */
.collapsed .folder-content,
.collapsed .module-content,
.collapsed .class-content {
display: none;
}
.toggle-icon {
margin-right: 4px;
transition: transform 0.2s;
}
.collapsed .toggle-icon {
transform: rotate(-90deg);
}
a {
color: #60a5fa;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Contract HTTP Tests</h1>
<div style="display: flex; gap: 12px; margin-top: 8px; font-size: 0.875rem;">
<a href="/tools/tester/" style="color: #60a5fa; text-decoration: none; font-weight: 600;">Runner</a>
<a href="/tools/tester/filters" style="color: #60a5fa; text-decoration: none;">Filters</a>
</div>
</div>
<div class="config-info">
<div style="display: flex; align-items: center; gap: 12px;">
<span>Target:</span>
<select id="environmentSelector" style="background: #374151; color: #e5e7eb; border: 1px solid #4b5563; border-radius: 4px; padding: 4px 8px; font-size: 0.875rem; cursor: pointer;">
<option value="">Loading...</option>
</select>
<strong id="currentUrl">{{ config.CONTRACT_TEST_URL }}</strong>
</div>
</div>
</header>
<div class="toolbar">
<button class="btn-primary" id="runAllBtn" onclick="runAll()">Run All</button>
<button class="btn-secondary" id="runSelectedBtn" onclick="runSelected()">Run Selected</button>
<button class="btn-secondary" onclick="clearResults()">Clear Results</button>
<span style="margin-left: auto; color: #6b7280;">{{ total_tests }} tests discovered</span>
</div>
<div class="main-content">
<div class="panel">
<div class="panel-header">
<span>Tests</span>
<button class="btn-secondary" onclick="toggleAll()" style="padding: 4px 8px; font-size: 0.75rem;">Toggle All</button>
</div>
<div class="panel-body" id="testsPanel">
{% for folder_name, folder in tests_tree.items() %}
<div class="folder" data-folder="{{ folder_name }}">
<div class="folder-header" onclick="toggleFolder(this)">
<span class="toggle-icon">&#9660;</span>
<input type="checkbox" onclick="event.stopPropagation(); toggleFolderCheckbox(this)" checked>
<span class="folder-name">{{ folder_name }}/</span>
<span class="test-count">{{ folder.test_count }}</span>
</div>
<div class="folder-content">
{% for module_name, module in folder.modules.items() %}
<div class="module" data-module="{{ folder_name }}.{{ module_name }}">
<div class="module-header" onclick="toggleModule(this)">
<span class="toggle-icon">&#9660;</span>
<input type="checkbox" onclick="event.stopPropagation(); toggleModuleCheckbox(this)" checked>
<span class="module-name">{{ module_name }}.py</span>
<span class="test-count">{{ module.test_count }}</span>
</div>
<div class="module-content">
{% for class_name, cls in module.classes.items() %}
<div class="class-block" data-class="{{ folder_name }}.{{ module_name }}.{{ class_name }}">
<div class="class-header" onclick="toggleClass(this)">
<span class="toggle-icon">&#9660;</span>
<input type="checkbox" onclick="event.stopPropagation(); toggleClassCheckbox(this)" checked>
<span>{{ class_name }}</span>
<span class="test-count">{{ cls.test_count }}</span>
</div>
<div class="class-content test-list">
{% for test in cls.tests %}
<div class="test-item">
<input type="checkbox" data-test-id="{{ test.id }}" checked>
<span class="test-name" title="{{ test.doc or '' }}">{{ test.name }}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="panel">
<div class="panel-header">
<span>Results</span>
<span id="runDuration" style="font-size: 0.75rem; color: #9ca3af;"></span>
</div>
<div class="panel-body" id="resultsPanel">
<div class="summary" id="summary" style="display: none;">
<div class="stat stat-passed">
<div class="stat-value" id="passedCount">0</div>
<div class="stat-label">Passed</div>
</div>
<div class="stat stat-failed">
<div class="stat-value" id="failedCount">0</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat stat-skipped">
<div class="stat-value" id="skippedCount">0</div>
<div class="stat-label">Skipped</div>
</div>
<div class="stat stat-running">
<div class="stat-value" id="runningCount">0</div>
<div class="stat-label">Running</div>
</div>
</div>
<div class="progress-bar" id="progressBar" style="display: none;">
<div class="progress-fill" id="progressFill" style="width: 0%;"></div>
</div>
<div class="current-test" id="currentTest" style="display: none;"></div>
<div id="resultsList">
<div class="empty-state">
Run tests to see results
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let currentRunId = null;
let pollInterval = null;
// Parse URL parameters for filters
const urlParams = new URLSearchParams(window.location.search);
const filterParams = {
search: urlParams.get('search') || '',
domains: urlParams.get('domains') ? new Set(urlParams.get('domains').split(',')) : new Set(),
modules: urlParams.get('modules') ? new Set(urlParams.get('modules').split(',')) : new Set(),
status: urlParams.get('status') ? new Set(urlParams.get('status').split(',')) : new Set(),
};
// Check if there's a run ID in URL
const autoRunId = urlParams.get('run');
// Format "TestCoverageCheck" -> "Coverage Check"
function formatClassName(name) {
// Remove "Test" prefix
let formatted = name.replace(/^Test/, '');
// Add space before each capital letter
formatted = formatted.replace(/([A-Z])/g, ' $1').trim();
return formatted;
}
// Format "test_returns_coverage_boolean" -> "returns coverage boolean"
function formatTestName(name) {
// Remove "test_" prefix
let formatted = name.replace(/^test_/, '');
// Replace underscores with spaces
formatted = formatted.replace(/_/g, ' ');
return formatted;
}
// Apply filters to test tree
function applyFilters() {
const folders = document.querySelectorAll('.folder');
folders.forEach(folder => {
const folderName = folder.dataset.folder;
let hasVisibleTests = false;
// Check domain filter
if (filterParams.domains.size > 0 && !filterParams.domains.has(folderName)) {
folder.style.display = 'none';
return;
}
// Check modules
const modules = folder.querySelectorAll('.module');
modules.forEach(module => {
const moduleName = module.dataset.module.split('.')[1];
let moduleVisible = true;
if (filterParams.modules.size > 0 && !filterParams.modules.has(moduleName)) {
moduleVisible = false;
}
// Check search filter on test names
if (filterParams.search && moduleVisible) {
const tests = module.querySelectorAll('.test-item');
let hasMatchingTest = false;
tests.forEach(test => {
const testName = test.querySelector('.test-name').textContent.toLowerCase();
if (testName.includes(filterParams.search.toLowerCase())) {
hasMatchingTest = true;
}
});
if (!hasMatchingTest) {
moduleVisible = false;
}
}
if (moduleVisible) {
module.style.display = '';
hasVisibleTests = true;
} else {
module.style.display = 'none';
}
});
folder.style.display = hasVisibleTests ? '' : 'none';
});
}
// Load environments
async function loadEnvironments() {
try {
const response = await fetch('/tools/tester/api/environments');
const data = await response.json();
const selector = document.getElementById('environmentSelector');
const currentUrl = document.getElementById('currentUrl');
// Get saved environment from localStorage
const savedEnvId = localStorage.getItem('selectedEnvironment');
let selectedEnv = null;
// Populate selector
selector.innerHTML = data.environments.map(env => {
const isDefault = env.default || env.id === savedEnvId;
if (isDefault) selectedEnv = env;
return `<option value="${env.id}" ${isDefault ? 'selected' : ''}>${env.name} ${env.has_api_key ? '🔑' : ''}</option>`;
}).join('');
// Update URL display
if (selectedEnv) {
currentUrl.textContent = selectedEnv.url;
}
// Handle environment changes
selector.addEventListener('change', async (e) => {
const envId = e.target.value;
try {
const response = await fetch(`/tools/tester/api/environment/select?env_id=${envId}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
currentUrl.textContent = data.environment.url;
localStorage.setItem('selectedEnvironment', envId);
// Show notification
const notification = document.createElement('div');
notification.textContent = `Switched to ${data.environment.name}`;
notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #2563eb; color: white; padding: 12px 20px; border-radius: 6px; z-index: 1000; animation: fadeIn 0.3s;';
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
} catch (error) {
console.error('Failed to switch environment:', error);
alert('Failed to switch environment');
}
});
} catch (error) {
console.error('Failed to load environments:', error);
}
}
// Apply formatting and filters on page load
document.addEventListener('DOMContentLoaded', function() {
// Load environments
loadEnvironments();
// Format class names
document.querySelectorAll('.class-header > span:not(.toggle-icon):not(.test-count)').forEach(el => {
if (!el.querySelector('input')) {
el.textContent = formatClassName(el.textContent);
}
});
// Format test names
document.querySelectorAll('.test-name').forEach(el => {
el.textContent = formatTestName(el.textContent);
});
// Apply filters from URL
if (filterParams.domains.size > 0 || filterParams.modules.size > 0 || filterParams.search) {
applyFilters();
}
// Auto-start run if run ID in URL
if (autoRunId) {
currentRunId = autoRunId;
document.getElementById('summary').style.display = 'flex';
document.getElementById('progressBar').style.display = 'block';
pollInterval = setInterval(pollStatus, 1000);
pollStatus();
}
});
function getSelectedTestIds() {
const checkboxes = document.querySelectorAll('.test-item input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.dataset.testId);
}
async function runAll() {
await startRun(null);
}
async function runSelected() {
const testIds = getSelectedTestIds();
if (testIds.length === 0) {
alert('No tests selected');
return;
}
await startRun(testIds);
}
async function startRun(testIds) {
document.getElementById('runAllBtn').disabled = true;
document.getElementById('runSelectedBtn').disabled = true;
document.getElementById('summary').style.display = 'flex';
document.getElementById('progressBar').style.display = 'block';
document.getElementById('resultsList').innerHTML = '';
try {
const response = await fetch('/tools/tester/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_ids: testIds }),
});
const data = await response.json();
currentRunId = data.run_id;
// Start polling
pollInterval = setInterval(pollStatus, 1000);
pollStatus(); // Immediate first poll
} catch (error) {
console.error('Failed to start run:', error);
document.getElementById('runAllBtn').disabled = false;
document.getElementById('runSelectedBtn').disabled = false;
}
}
async function pollStatus() {
if (!currentRunId) return;
try {
const response = await fetch(`/tools/tester/api/run/${currentRunId}`);
const data = await response.json();
updateUI(data);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(pollInterval);
pollInterval = null;
document.getElementById('runAllBtn').disabled = false;
document.getElementById('runSelectedBtn').disabled = false;
}
} catch (error) {
console.error('Poll failed:', error);
}
}
function updateUI(data) {
// Update counts
document.getElementById('passedCount').textContent = data.passed;
document.getElementById('failedCount').textContent = data.failed + data.errors;
document.getElementById('skippedCount').textContent = data.skipped;
document.getElementById('runningCount').textContent = data.total - data.completed;
// Update progress
const progress = data.total > 0 ? (data.completed / data.total * 100) : 0;
document.getElementById('progressFill').style.width = progress + '%';
// Update duration
if (data.duration) {
document.getElementById('runDuration').textContent = data.duration.toFixed(1) + 's';
}
// Current test
const currentTestEl = document.getElementById('currentTest');
if (data.current_test) {
currentTestEl.textContent = 'Running: ' + data.current_test;
currentTestEl.style.display = 'block';
} else {
currentTestEl.style.display = 'none';
}
// Results list
const resultsList = document.getElementById('resultsList');
resultsList.innerHTML = data.results.map(r => renderResult(r)).join('');
}
function renderResult(result) {
const icons = {
passed: '&#10003;',
failed: '&#10007;',
error: '&#10007;',
skipped: '&#8722;',
running: '&#9679;',
};
let errorHtml = '';
if (result.error_message) {
errorHtml = `<div class="result-error">${escapeHtml(result.error_message)}</div>`;
}
// Render artifacts (videos, screenshots)
let artifactsHtml = '';
if (result.artifacts && result.artifacts.length > 0) {
const artifactItems = result.artifacts.map(artifact => {
if (artifact.type === 'video') {
return `
<div style="margin-top: 8px;">
<div style="font-size: 0.75rem; color: #9ca3af; margin-bottom: 4px;">
📹 ${artifact.filename} (${formatBytes(artifact.size)})
</div>
<video controls style="max-width: 100%; border-radius: 4px; background: #000;">
<source src="${artifact.url}" type="video/webm">
Your browser does not support video playback.
</video>
</div>
`;
} else if (artifact.type === 'screenshot') {
return `
<div style="margin-top: 8px;">
<div style="font-size: 0.75rem; color: #9ca3af; margin-bottom: 4px;">
📸 ${artifact.filename} (${formatBytes(artifact.size)})
</div>
<img src="${artifact.url}" style="max-width: 100%; border-radius: 4px; border: 1px solid #374151;">
</div>
`;
} else {
return `
<div style="margin-top: 8px; font-size: 0.75rem; color: #9ca3af;">
📎 <a href="${artifact.url}" style="color: #60a5fa;">${artifact.filename}</a> (${formatBytes(artifact.size)})
</div>
`;
}
}).join('');
artifactsHtml = `<div class="result-artifacts">${artifactItems}</div>`;
}
return `
<div class="result-item result-${result.status}">
<div class="result-icon">${icons[result.status] || '?'}</div>
<div class="result-info">
<div class="result-name">${escapeHtml(result.name)}</div>
<div class="result-test-id">${escapeHtml(result.test_id)}</div>
${errorHtml}
${artifactsHtml}
</div>
<div class="result-duration">${result.duration.toFixed(3)}s</div>
</div>
`;
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function clearResults() {
document.getElementById('summary').style.display = 'none';
document.getElementById('progressBar').style.display = 'none';
document.getElementById('currentTest').style.display = 'none';
document.getElementById('runDuration').textContent = '';
document.getElementById('resultsList').innerHTML = '<div class="empty-state">Run tests to see results</div>';
}
// Toggle functions
function toggleFolder(header) {
header.parentElement.classList.toggle('collapsed');
}
function toggleModule(header) {
header.parentElement.classList.toggle('collapsed');
}
function toggleClass(header) {
header.parentElement.classList.toggle('collapsed');
}
function toggleAll() {
const folders = document.querySelectorAll('.folder');
const allCollapsed = Array.from(folders).every(f => f.classList.contains('collapsed'));
folders.forEach(folder => {
if (allCollapsed) {
folder.classList.remove('collapsed');
} else {
folder.classList.add('collapsed');
}
});
}
function toggleFolderCheckbox(checkbox) {
const folder = checkbox.closest('.folder');
const childCheckboxes = folder.querySelectorAll('input[type="checkbox"]');
childCheckboxes.forEach(cb => cb.checked = checkbox.checked);
}
function toggleModuleCheckbox(checkbox) {
const module = checkbox.closest('.module');
const childCheckboxes = module.querySelectorAll('.test-item input[type="checkbox"]');
childCheckboxes.forEach(cb => cb.checked = checkbox.checked);
}
function toggleClassCheckbox(checkbox) {
const classBlock = checkbox.closest('.class-block');
const childCheckboxes = classBlock.querySelectorAll('.test-item input[type="checkbox"]');
childCheckboxes.forEach(cb => cb.checked = checkbox.checked);
}
</script>
</body>
</html>