From 8cdd656968068b6875694f84368e44b8359b5438 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Fri, 9 Jan 2026 15:35:13 +0000 Subject: [PATCH] Implement Workflow Builder and Refactor UI Tests: Add visual workflow builder for dynamic task management, enhance Playwright tests to improve UI automation coverage, and introduce a new base HTML template. --- prompt.yml | 2 + src/autometabuilder/web/server.py | 6 + src/autometabuilder/web/static/css/main.css | 640 ++++++++++++++++++ src/autometabuilder/web/static/js/main.js | 308 +++++++++ src/autometabuilder/web/static/js/workflow.js | 279 ++++++++ src/autometabuilder/web/templates/base.html | 74 ++ src/autometabuilder/web/templates/index.html | 551 ++++++--------- tests/ui/test_web_ui.py | 248 ++++--- 8 files changed, 1679 insertions(+), 429 deletions(-) create mode 100644 src/autometabuilder/web/static/css/main.css create mode 100644 src/autometabuilder/web/static/js/main.js create mode 100644 src/autometabuilder/web/static/js/workflow.js create mode 100644 src/autometabuilder/web/templates/base.html diff --git a/prompt.yml b/prompt.yml index d3b2c5b..9005f01 100644 --- a/prompt.yml +++ b/prompt.yml @@ -29,4 +29,6 @@ model: openai/gpt-4o # Test Comment # Test Comment # Test Comment +# Test Comment +# Test Comment # Test Comment \ No newline at end of file diff --git a/src/autometabuilder/web/server.py b/src/autometabuilder/web/server.py index d7b2d66..b31a5c8 100644 --- a/src/autometabuilder/web/server.py +++ b/src/autometabuilder/web/server.py @@ -4,6 +4,7 @@ import secrets from fastapi import FastAPI, Request, Form, BackgroundTasks, Depends, HTTPException, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials from dotenv import load_dotenv, set_key import subprocess @@ -39,6 +40,11 @@ mock_running = False templates_dir = os.path.join(os.path.dirname(__file__), "templates") templates = Jinja2Templates(directory=templates_dir) +# Setup static files +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.exists(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + def run_bot_task(): global bot_process, mock_running if os.environ.get("MOCK_WEB_UI") == "true": diff --git a/src/autometabuilder/web/static/css/main.css b/src/autometabuilder/web/static/css/main.css new file mode 100644 index 0000000..9bdb2e5 --- /dev/null +++ b/src/autometabuilder/web/static/css/main.css @@ -0,0 +1,640 @@ +/* ========================================================================== + AutoMetabuilder - Main Stylesheet + ========================================================================== */ + +/* ========================================================================== + 1. CSS Custom Properties (Theme Variables) + ========================================================================== */ +:root { + /* Light mode (default) */ + --amb-bg-primary: #f8f9fa; + --amb-bg-secondary: #ffffff; + --amb-bg-tertiary: #e9ecef; + --amb-text-primary: #212529; + --amb-text-secondary: #495057; + --amb-text-muted: #6c757d; + --amb-border-color: #dee2e6; + --amb-card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --amb-sidebar-bg: #212529; + --amb-sidebar-text: #adb5bd; + --amb-sidebar-text-active: #ffffff; + --amb-sidebar-hover: rgba(255, 255, 255, 0.1); + --amb-code-bg: #f1f3f5; + --amb-accent: #0d6efd; + --amb-accent-hover: #0b5ed7; + --amb-success: #198754; + --amb-warning: #ffc107; + --amb-danger: #dc3545; + --amb-sidebar-width: 260px; + --amb-transition: 0.2s ease; +} + +/* Dark mode */ +[data-theme="dark"] { + --amb-bg-primary: #1a1d21; + --amb-bg-secondary: #212529; + --amb-bg-tertiary: #2c3034; + --amb-text-primary: #f8f9fa; + --amb-text-secondary: #ced4da; + --amb-text-muted: #adb5bd; + --amb-border-color: #495057; + --amb-card-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3); + --amb-sidebar-bg: #0d1117; + --amb-sidebar-text: #8b949e; + --amb-sidebar-text-active: #f0f6fc; + --amb-sidebar-hover: rgba(255, 255, 255, 0.05); + --amb-code-bg: #2d333b; + --amb-accent: #58a6ff; + --amb-accent-hover: #79b8ff; + --amb-success: #3fb950; + --amb-warning: #d29922; + --amb-danger: #f85149; +} + +/* ========================================================================== + 2. Base Styles + ========================================================================== */ +* { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + margin: 0; + padding: 0; + min-height: 100%; + background-color: var(--amb-bg-primary); + color: var(--amb-text-primary); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + transition: background-color var(--amb-transition), color var(--amb-transition); +} + +/* ========================================================================== + 3. Layout + ========================================================================== */ +.amb-layout { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +.amb-sidebar { + width: var(--amb-sidebar-width); + background: var(--amb-sidebar-bg); + color: var(--amb-sidebar-text); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1000; + transition: background-color var(--amb-transition); +} + +.amb-sidebar-header { + padding: 1.5rem 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.amb-sidebar-header h5 { + margin: 0; + color: var(--amb-sidebar-text-active); + font-weight: 600; + font-size: 1.1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.amb-sidebar-header .bi { + font-size: 1.25rem; +} + +/* Navigation */ +.amb-nav { + flex: 1; + padding: 1rem 0; + overflow-y: auto; +} + +.amb-nav-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.25rem; + color: var(--amb-sidebar-text); + text-decoration: none; + font-size: 0.9rem; + transition: all var(--amb-transition); + border-left: 3px solid transparent; +} + +.amb-nav-link:hover { + background: var(--amb-sidebar-hover); + color: var(--amb-sidebar-text-active); +} + +.amb-nav-link.active { + background: var(--amb-sidebar-hover); + color: var(--amb-sidebar-text-active); + border-left-color: var(--amb-accent); +} + +.amb-nav-link .bi { + font-size: 1.1rem; + width: 1.25rem; + text-align: center; +} + +/* Sidebar Footer */ +.amb-sidebar-footer { + padding: 1rem 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.amb-sidebar-footer .amb-user { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--amb-sidebar-text); +} + +.amb-theme-toggle { + background: transparent; + border: none; + color: var(--amb-sidebar-text); + cursor: pointer; + padding: 0.5rem; + border-radius: 0.375rem; + transition: all var(--amb-transition); + display: flex; + align-items: center; + justify-content: center; +} + +.amb-theme-toggle:hover { + background: var(--amb-sidebar-hover); + color: var(--amb-sidebar-text-active); +} + +.amb-theme-toggle .bi { + font-size: 1.1rem; +} + +/* Main Content */ +.amb-main { + flex: 1; + margin-left: var(--amb-sidebar-width); + padding: 2rem; + min-height: 100vh; +} + +/* Sections */ +.amb-section { + display: none; +} + +.amb-section.active { + display: block; +} + +.amb-section-header { + margin-bottom: 1.5rem; +} + +.amb-section-header h1 { + margin: 0 0 0.25rem 0; + font-size: 1.75rem; + font-weight: 600; +} + +.amb-section-header p { + margin: 0; + color: var(--amb-text-muted); +} + +/* ========================================================================== + 4. Cards + ========================================================================== */ +.amb-card { + background: var(--amb-bg-secondary); + border: 1px solid var(--amb-border-color); + border-radius: 0.75rem; + box-shadow: var(--amb-card-shadow); + margin-bottom: 1.5rem; + transition: background-color var(--amb-transition), border-color var(--amb-transition), box-shadow var(--amb-transition); +} + +.amb-card-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--amb-border-color); + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.amb-card-header h5 { + margin: 0; + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.amb-card-body { + padding: 1.25rem; +} + +.amb-card-footer { + padding: 1rem 1.25rem; + border-top: 1px solid var(--amb-border-color); + background: var(--amb-bg-tertiary); + border-radius: 0 0 0.75rem 0.75rem; +} + +/* ========================================================================== + 5. Forms + ========================================================================== */ +.amb-form-group { + margin-bottom: 1.25rem; +} + +.amb-form-label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.amb-required { + color: var(--amb-danger); + margin-left: 0.25rem; +} + +/* Form control overrides for dark mode */ +[data-theme="dark"] .form-control, +[data-theme="dark"] .form-select { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +[data-theme="dark"] .form-control:focus, +[data-theme="dark"] .form-select:focus { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-accent); + color: var(--amb-text-primary); + box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25); +} + +[data-theme="dark"] .form-control::placeholder { + color: var(--amb-text-muted); +} + +[data-theme="dark"] .form-control:disabled, +[data-theme="dark"] .form-select:disabled { + background-color: var(--amb-bg-primary); + opacity: 0.65; +} + +/* Validation states */ +.form-control.is-valid, +.form-select.is-valid { + border-color: var(--amb-success); +} + +.form-control.is-invalid, +.form-select.is-invalid { + border-color: var(--amb-danger); +} + +/* ========================================================================== + 6. Choices.js Overrides + ========================================================================== */ +.choices { + margin-bottom: 0; +} + +.choices__inner { + background-color: var(--amb-bg-secondary); + border-color: var(--amb-border-color); + border-radius: 0.375rem; + min-height: 38px; + padding: 0.375rem 0.75rem; + transition: border-color var(--amb-transition), box-shadow var(--amb-transition); +} + +.choices__inner:focus-within { + border-color: var(--amb-accent); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +.choices__list--dropdown { + background-color: var(--amb-bg-secondary); + border-color: var(--amb-border-color); + border-radius: 0.375rem; + box-shadow: var(--amb-card-shadow); + z-index: 100; +} + +.choices__list--dropdown .choices__item--selectable.is-highlighted { + background-color: var(--amb-accent); + color: #fff; +} + +.choices__input { + background-color: transparent !important; + color: var(--amb-text-primary); +} + +.choices__placeholder { + color: var(--amb-text-muted); + opacity: 1; +} + +.choices[data-type*="select-one"] .choices__input { + background-color: var(--amb-bg-secondary) !important; +} + +/* Dark mode Choices.js */ +[data-theme="dark"] .choices__inner { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-border-color); +} + +[data-theme="dark"] .choices__inner:focus-within { + border-color: var(--amb-accent); + box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25); +} + +[data-theme="dark"] .choices__list--dropdown { + background-color: var(--amb-bg-secondary); + border-color: var(--amb-border-color); +} + +[data-theme="dark"] .choices__list--dropdown .choices__item { + color: var(--amb-text-primary); +} + +[data-theme="dark"] .choices[data-type*="select-one"] .choices__input { + background-color: var(--amb-bg-tertiary) !important; + color: var(--amb-text-primary); +} + +/* ========================================================================== + 7. Tables + ========================================================================== */ +.amb-table { + background: var(--amb-bg-secondary); +} + +[data-theme="dark"] .table { + --bs-table-bg: var(--amb-bg-secondary); + --bs-table-color: var(--amb-text-primary); + --bs-table-border-color: var(--amb-border-color); + --bs-table-striped-bg: var(--amb-bg-tertiary); + --bs-table-hover-bg: var(--amb-bg-tertiary); +} + +/* ========================================================================== + 8. Logs Panel + ========================================================================== */ +.amb-logs { + background: var(--amb-code-bg); + border: 1px solid var(--amb-border-color); + border-radius: 0.5rem; + padding: 1rem; + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-size: 0.8rem; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + transition: background-color var(--amb-transition), border-color var(--amb-transition); +} + +/* ========================================================================== + 9. Status Indicators + ========================================================================== */ +.amb-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-weight: 500; + font-size: 0.9rem; +} + +.amb-status-idle { + background: rgba(25, 135, 84, 0.1); + color: var(--amb-success); +} + +.amb-status-running { + background: rgba(255, 193, 7, 0.1); + color: var(--amb-warning); +} + +.amb-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.amb-status-running .amb-status-dot { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ========================================================================== + 10. Workflow Builder + ========================================================================== */ +.amb-workflow-task { + background: var(--amb-bg-secondary); + border: 1px solid var(--amb-border-color); + border-radius: 0.75rem; + margin-bottom: 1rem; + box-shadow: var(--amb-card-shadow); + transition: background-color var(--amb-transition), border-color var(--amb-transition); +} + +.amb-workflow-task-header { + background: var(--amb-accent); + color: #fff; + padding: 0.75rem 1rem; + border-radius: 0.75rem 0.75rem 0 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.amb-workflow-task-body { + padding: 1rem; +} + +.amb-workflow-step { + background: var(--amb-bg-tertiary); + border: 1px solid var(--amb-border-color); + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 0.75rem; + transition: background-color var(--amb-transition), border-color var(--amb-transition); +} + +.amb-workflow-step:last-child { + margin-bottom: 0; +} + +.amb-workflow-step-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +/* ========================================================================== + 11. Buttons + ========================================================================== */ +[data-theme="dark"] .btn-light { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +[data-theme="dark"] .btn-light:hover { + background-color: var(--amb-bg-secondary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +[data-theme="dark"] .btn-outline-primary { + color: var(--amb-accent); + border-color: var(--amb-accent); +} + +[data-theme="dark"] .btn-outline-primary:hover { + background-color: var(--amb-accent); + border-color: var(--amb-accent); + color: #fff; +} + +[data-theme="dark"] .btn-outline-secondary { + color: var(--amb-text-secondary); + border-color: var(--amb-border-color); +} + +[data-theme="dark"] .btn-outline-secondary:hover { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +/* ========================================================================== + 12. Badges + ========================================================================== */ +[data-theme="dark"] .badge.bg-light { + background-color: var(--amb-bg-tertiary) !important; + color: var(--amb-text-primary) !important; +} + +/* ========================================================================== + 13. List Groups + ========================================================================== */ +[data-theme="dark"] .list-group-item { + background-color: var(--amb-bg-secondary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +/* ========================================================================== + 14. Input Groups + ========================================================================== */ +[data-theme="dark"] .input-group-text { + background-color: var(--amb-bg-tertiary); + border-color: var(--amb-border-color); + color: var(--amb-text-primary); +} + +/* ========================================================================== + 15. Textarea (Code Editor) + ========================================================================== */ +.amb-code-editor { + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-size: 0.85rem; + line-height: 1.6; + background: var(--amb-code-bg); + border: 1px solid var(--amb-border-color); + border-radius: 0.5rem; + padding: 1rem; + resize: vertical; + transition: background-color var(--amb-transition), border-color var(--amb-transition); +} + +[data-theme="dark"] .amb-code-editor { + background: var(--amb-code-bg); + color: var(--amb-text-primary); +} + +/* ========================================================================== + 16. Utilities + ========================================================================== */ +.amb-scrollable { + max-height: 400px; + overflow-y: auto; +} + +.amb-gap-2 { + gap: 0.5rem; +} + +.amb-gap-3 { + gap: 1rem; +} + +/* ========================================================================== + 17. Responsive + ========================================================================== */ +@media (max-width: 991.98px) { + .amb-sidebar { + transform: translateX(-100%); + transition: transform var(--amb-transition); + } + + .amb-sidebar.show { + transform: translateX(0); + } + + .amb-main { + margin-left: 0; + } +} + +/* ========================================================================== + 18. Transitions + ========================================================================== */ +.amb-fade-in { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/autometabuilder/web/static/js/main.js b/src/autometabuilder/web/static/js/main.js new file mode 100644 index 0000000..5eb713e --- /dev/null +++ b/src/autometabuilder/web/static/js/main.js @@ -0,0 +1,308 @@ +/** + * AutoMetabuilder - Main JavaScript + */ + +/* ========================================================================== + Theme Manager + ========================================================================== */ +const ThemeManager = { + STORAGE_KEY: 'amb-theme', + + init() { + const saved = localStorage.getItem(this.STORAGE_KEY); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = saved || (prefersDark ? 'dark' : 'light'); + this.setTheme(theme); + + document.querySelectorAll('[data-theme-toggle]').forEach(btn => { + btn.addEventListener('click', () => this.toggle()); + }); + + window.matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', e => { + if (!localStorage.getItem(this.STORAGE_KEY)) { + this.setTheme(e.matches ? 'dark' : 'light'); + } + }); + }, + + setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(this.STORAGE_KEY, theme); + this.updateToggleIcon(theme); + }, + + toggle() { + const current = document.documentElement.getAttribute('data-theme'); + this.setTheme(current === 'dark' ? 'light' : 'dark'); + }, + + updateToggleIcon(theme) { + document.querySelectorAll('[data-theme-toggle] i').forEach(icon => { + icon.className = theme === 'dark' ? 'bi bi-moon-fill' : 'bi bi-sun-fill'; + }); + } +}; + +/* ========================================================================== + Navigation Manager + ========================================================================== */ +const NavigationManager = { + init() { + document.querySelectorAll('[data-section]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const target = link.dataset.section; + this.showSection(target); + }); + }); + + // Handle initial hash + const hash = window.location.hash.slice(1); + if (hash && document.querySelector(`#${hash}`)) { + this.showSection(hash); + } + + // Handle browser back/forward + window.addEventListener('popstate', () => { + const hash = window.location.hash.slice(1); + if (hash && document.querySelector(`#${hash}`)) { + this.showSection(hash, false); + } + }); + }, + + showSection(sectionId, updateHistory = true) { + // Hide all sections + document.querySelectorAll('.amb-section').forEach(s => s.classList.remove('active')); + + // Show target section + const targetSection = document.querySelector(`#${sectionId}`); + if (targetSection) { + targetSection.classList.add('active'); + } + + // Update nav active state + document.querySelectorAll('.amb-nav-link').forEach(n => n.classList.remove('active')); + const activeLink = document.querySelector(`[data-section="${sectionId}"]`); + if (activeLink) { + activeLink.classList.add('active'); + } + + // Update URL hash + if (updateHistory) { + history.pushState(null, '', `#${sectionId}`); + } + } +}; + +/* ========================================================================== + Choices Manager + ========================================================================== */ +const ChoicesManager = { + instances: [], + + init() { + this.initAll(); + }, + + initAll() { + document.querySelectorAll('[data-choices]').forEach(el => { + // Skip if already initialized + if (el.classList.contains('choices__input')) return; + + const optionsStr = el.dataset.choicesOptions || '{}'; + let options = {}; + try { + options = JSON.parse(optionsStr); + } catch (e) { + console.warn('Invalid choices options:', optionsStr); + } + + const instance = new Choices(el, { + searchEnabled: true, + shouldSort: false, + allowHTML: false, + removeItemButton: options.removeItemButton || false, + placeholder: true, + placeholderValue: el.dataset.placeholder || 'Select...', + searchPlaceholderValue: 'Type to search...', + noResultsText: 'No results found', + noChoicesText: 'No choices available', + ...options + }); + this.instances.push(instance); + }); + }, + + destroy() { + this.instances.forEach(instance => { + try { + instance.destroy(); + } catch (e) { + // Ignore errors from already destroyed instances + } + }); + this.instances = []; + }, + + refresh() { + this.destroy(); + this.initAll(); + } +}; + +/* ========================================================================== + Form Validator + ========================================================================== */ +const FormValidator = { + init() { + document.querySelectorAll('form[data-validate]').forEach(form => { + form.addEventListener('submit', event => { + if (!form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } + form.classList.add('was-validated'); + }); + }); + } +}; + +/* ========================================================================== + Status Poller + ========================================================================== */ +const StatusPoller = { + logsInterval: null, + statusInterval: null, + + init() { + this.refreshLogs(); + this.refreshStatus(); + this.logsInterval = setInterval(() => this.refreshLogs(), 2000); + this.statusInterval = setInterval(() => this.refreshStatus(), 2000); + }, + + async refreshLogs() { + try { + const response = await fetch('/api/logs'); + const data = await response.json(); + const logsPre = document.getElementById('logs'); + if (!logsPre) return; + + const wasAtBottom = logsPre.scrollHeight - logsPre.clientHeight <= logsPre.scrollTop + 1; + logsPre.textContent = data.logs; + if (wasAtBottom) { + logsPre.scrollTop = logsPre.scrollHeight; + } + } catch (error) { + console.error('Error fetching logs:', error); + } + }, + + async refreshStatus() { + try { + const response = await fetch('/api/status'); + const data = await response.json(); + + // Update status indicator + const statusIndicator = document.getElementById('status-indicator'); + if (statusIndicator) { + if (data.is_running) { + statusIndicator.className = 'amb-status amb-status-running'; + statusIndicator.innerHTML = ' Running'; + } else { + statusIndicator.className = 'amb-status amb-status-idle'; + statusIndicator.innerHTML = ' Idle'; + } + } + + // Update MVP badge + const mvpBadge = document.getElementById('mvp-badge'); + if (mvpBadge) { + if (data.mvp_reached) { + mvpBadge.className = 'badge bg-primary'; + mvpBadge.innerHTML = ' Reached'; + } else { + mvpBadge.className = 'badge bg-secondary'; + mvpBadge.innerHTML = ' In Progress'; + } + } + + // Update run button + const runBtn = document.getElementById('run-btn'); + if (runBtn) { + runBtn.disabled = data.is_running; + } + + // Update progress bar visibility + const progressBar = document.getElementById('status-progress'); + if (progressBar) { + progressBar.style.display = data.is_running ? 'block' : 'none'; + } + } catch (error) { + console.error('Error fetching status:', error); + } + } +}; + +/* ========================================================================== + Toast Notifications + ========================================================================== */ +const Toast = { + show(message, type = 'info') { + const container = document.getElementById('toast-container') || this.createContainer(); + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-bg-${type} border-0 show`; + toast.setAttribute('role', 'alert'); + toast.innerHTML = ` +
+
${message}
+ +
+ `; + container.appendChild(toast); + + // Auto remove after 5 seconds + setTimeout(() => { + toast.remove(); + }, 5000); + + // Close button + toast.querySelector('.btn-close').addEventListener('click', () => toast.remove()); + }, + + createContainer() { + const container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + container.style.zIndex = '1100'; + document.body.appendChild(container); + return container; + } +}; + +/* ========================================================================== + App Initialization + ========================================================================== */ +const App = { + init() { + ThemeManager.init(); + NavigationManager.init(); + ChoicesManager.init(); + FormValidator.init(); + StatusPoller.init(); + } +}; + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', () => App.init()); + +// Export for use in other scripts +window.App = App; +window.ThemeManager = ThemeManager; +window.NavigationManager = NavigationManager; +window.ChoicesManager = ChoicesManager; +window.FormValidator = FormValidator; +window.StatusPoller = StatusPoller; +window.Toast = Toast; diff --git a/src/autometabuilder/web/static/js/workflow.js b/src/autometabuilder/web/static/js/workflow.js new file mode 100644 index 0000000..e31a5f9 --- /dev/null +++ b/src/autometabuilder/web/static/js/workflow.js @@ -0,0 +1,279 @@ +/** + * AutoMetabuilder - Workflow Builder + */ + +const WorkflowBuilder = { + workflow: [], + stepDefinitions: {}, + allSuggestions: new Set(), + container: null, + textarea: null, + + init(containerId, textareaId, stepDefinitions) { + this.container = document.getElementById(containerId); + this.textarea = document.getElementById(textareaId); + this.stepDefinitions = stepDefinitions || {}; + + // Build all suggestions set + Object.values(this.stepDefinitions).forEach(def => { + Object.values(def.fields || {}).forEach(f => { + if (f.suggestions) { + f.suggestions.forEach(s => this.allSuggestions.add(s)); + } + }); + }); + + // Parse initial workflow + try { + this.workflow = JSON.parse(this.textarea.value || '[]'); + } catch (e) { + console.error('Failed to parse workflow JSON', e); + this.workflow = []; + } + + this.render(); + }, + + render() { + if (!this.container) return; + + this.container.innerHTML = ''; + + this.workflow.forEach((task, taskIdx) => { + const taskCard = document.createElement('div'); + taskCard.className = 'amb-workflow-task'; + taskCard.innerHTML = ` +
+
+ + + ${task.type === 'loop' ? ` + + ` : ''} +
+
+ + + +
+
+
+
+ +
+ `; + this.container.appendChild(taskCard); + + const stepsContainer = taskCard.querySelector(`#steps-${taskIdx}`); + (task.steps || []).forEach((step, stepIdx) => { + const stepDiv = this.createStepElement(task, taskIdx, step, stepIdx); + stepsContainer.appendChild(stepDiv); + }); + }); + + // Add task button + const addTaskBtn = document.createElement('button'); + addTaskBtn.className = 'btn btn-primary'; + addTaskBtn.innerHTML = ' Add Task'; + addTaskBtn.onclick = () => this.addTask(); + this.container.appendChild(addTaskBtn); + + this.sync(); + + // Reinitialize Choices.js for new elements + if (window.ChoicesManager) { + window.ChoicesManager.refresh(); + } + }, + + createStepElement(task, taskIdx, step, stepIdx) { + const stepDiv = document.createElement('div'); + stepDiv.className = 'amb-workflow-step'; + + let fieldsHtml = ` + + `; + + if (step.type && this.stepDefinitions[step.type]) { + fieldsHtml += '
'; + const def = this.stepDefinitions[step.type]; + Object.entries(def.fields || {}).forEach(([field, fieldDef]) => { + const val = step[field] !== undefined ? step[field] : ''; + const isCheckbox = fieldDef.type === 'checkbox'; + const suggestions = fieldDef.suggestions || Array.from(this.allSuggestions); + + fieldsHtml += ` +
+ + ${isCheckbox ? ` +
+ +
+ ` : ` + + `} +
+ `; + }); + fieldsHtml += '
'; + } + + stepDiv.innerHTML = ` +
+ Step ${stepIdx + 1} +
+ + + +
+
+ ${fieldsHtml} + `; + + return stepDiv; + }, + + sync() { + if (this.textarea) { + this.textarea.value = JSON.stringify(this.workflow, null, 2); + } + }, + + toggleRaw() { + if (!this.textarea) return; + + this.textarea.classList.toggle('d-none'); + if (!this.textarea.classList.contains('d-none')) { + this.textarea.oninput = () => { + try { + this.workflow = JSON.parse(this.textarea.value); + this.render(); + } catch (e) { + // Invalid JSON, ignore + } + }; + } + }, + + updateTask(idx, field, value) { + this.workflow[idx][field] = value; + if (field === 'type' && value !== 'loop') { + delete this.workflow[idx].max_iterations; + } else if (field === 'type' && value === 'loop' && !this.workflow[idx].max_iterations) { + this.workflow[idx].max_iterations = 10; + } + this.render(); + }, + + addTask() { + this.workflow.push({ name: 'New Task', steps: [] }); + this.render(); + }, + + removeTask(idx) { + if (confirm('Remove this task?')) { + this.workflow.splice(idx, 1); + this.render(); + } + }, + + moveTask(idx, dir) { + const target = idx + dir; + if (target >= 0 && target < this.workflow.length) { + [this.workflow[idx], this.workflow[target]] = [this.workflow[target], this.workflow[idx]]; + this.render(); + } + }, + + updateStepType(taskIdx, stepIdx, type) { + const newStep = { type: type }; + if (this.stepDefinitions[type]) { + Object.entries(this.stepDefinitions[type].fields || {}).forEach(([field, fieldDef]) => { + newStep[field] = fieldDef.default; + }); + } + this.workflow[taskIdx].steps[stepIdx] = newStep; + this.render(); + }, + + updateStepField(taskIdx, stepIdx, field, value) { + this.workflow[taskIdx].steps[stepIdx][field] = value; + this.sync(); + }, + + addStep(taskIdx) { + if (!this.workflow[taskIdx].steps) { + this.workflow[taskIdx].steps = []; + } + this.workflow[taskIdx].steps.push({ type: '' }); + this.render(); + }, + + removeStep(taskIdx, stepIdx) { + this.workflow[taskIdx].steps.splice(stepIdx, 1); + this.render(); + }, + + moveStep(taskIdx, stepIdx, dir) { + const target = stepIdx + dir; + const steps = this.workflow[taskIdx].steps; + if (target >= 0 && target < steps.length) { + [steps[stepIdx], steps[target]] = [steps[target], steps[stepIdx]]; + this.render(); + } + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; + +// Export globally +window.WorkflowBuilder = WorkflowBuilder; diff --git a/src/autometabuilder/web/templates/base.html b/src/autometabuilder/web/templates/base.html new file mode 100644 index 0000000..e85b0a8 --- /dev/null +++ b/src/autometabuilder/web/templates/base.html @@ -0,0 +1,74 @@ + + + + + + {% block title %}AutoMetabuilder Dashboard{% endblock %} + + + + + + + + + + + + + + {% block head %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/src/autometabuilder/web/templates/index.html b/src/autometabuilder/web/templates/index.html index 83b5942..19f7762 100644 --- a/src/autometabuilder/web/templates/index.html +++ b/src/autometabuilder/web/templates/index.html @@ -1,381 +1,240 @@ - - - - AutoMetabuilder Dashboard - - - -
-

AutoMetabuilder Dashboard

- Logged in as: {{ username }} +{% extends "base.html" %} + +{% block content %} + +
+
+

Dashboard

+

Monitor system status and recent activity

- -
-
-

Recent Logs

-
{{ logs }}
-
-
-

System Status

-
-

Status: - {% if is_running %} - Bot Running... - {% else %} - Idle - {% endif %} -

-

MVP Milestone: - {% if mvp_reached %} - Reached ✓ - {% else %} - In Progress - {% endif %} -

+ +
+ +
+
+
+
System Status
+
+
+
+
+ Bot Status +
+ + {% if is_running %}Running{% else %}Idle{% endif %} +
+
+
+ MVP Milestone + + {% if mvp_reached %} + Reached + {% else %} + In Progress + {% endif %} + +
+
+
+
+
+
+
- -
- -
- -

Translations

-
    - {% for lang, file in translations.items() %} -
  • {{ lang }} ({{ file }})
  • - {% endfor %} -
-
-
- - - {% for lang in metadata.suggestions.languages %} - - +
+ + +
+
+
+
Recent Logs
+
+
+
{{ logs }}
+
+
+
+
+
+ + +
+
+

Workflow Builder

+

Configure the bot's task execution workflow

+
+ +
+
+
+ +
+ + +
+ +
+
-
-
-

Settings (.env)

-
- + +
+
+

Prompt Editor

+

Edit the system prompt configuration (prompt.yml)

+
+ +
+
+ + +
Prompt content is required.
+ + +
+
+
+ + +
+
+

Settings

+

Manage environment variables (.env)

+
+ +
+
+
+
- + {% for key, value in env_vars.items() %} - + {% endfor %} - +
KeyKey Value
{{ key }}{{ key }} - +
- + - +
- - {% for key in env_vars.keys() %} - - - {% for value in env_vars.values() %} - - - -
-
-

Prompt (prompt.yml)

-
- - +
+ -
-
-

Workflow Builder (workflow.json)

-
- -
-
- - {% for name in metadata.suggestions.task_names %} - - - - -
-
+ +
+
+

Translations

+

Manage language translations

- - - +{% block scripts %} + + +{% endblock %} diff --git a/tests/ui/test_web_ui.py b/tests/ui/test_web_ui.py index 779edb7..3904ac4 100644 --- a/tests/ui/test_web_ui.py +++ b/tests/ui/test_web_ui.py @@ -1,124 +1,206 @@ import pytest import json import os +import re from playwright.sync_api import Page, expect def test_login_and_dashboard(page: Page, server: str): - # Go to the server - page.goto(server) - - # We should be prompted for basic auth - # Playwright handles basic auth via context or by encoding it in the URL - # or we can use the page.authenticate method - - # Alternative: use URL with credentials + # Go to the server with auth auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - - # Check if we are on the dashboard - expect(page.locator("h1")).to_contain_text("AutoMetabuilder Dashboard") - expect(page.locator("text=Logged in as: testuser")).to_be_visible() + + # Check if we are on the dashboard - select h1 in the active section + expect(page.locator("#dashboard.active h1")).to_contain_text("Dashboard") + # User info is now in sidebar footer + expect(page.locator(".amb-sidebar-footer")).to_contain_text("testuser") def test_run_bot_mock(page: Page, server: str): auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - + # Click run button page.click("button:has-text('Run Bot')") - - # Status should change to "Bot Running..." - expect(page.locator(".badge.bg-warning")).to_contain_text("Bot Running") - - # Wait for it to finish (mock takes 5 seconds) - page.wait_for_timeout(6000) - - # Refresh - page.reload() - expect(page.locator(".badge.bg-success")).to_contain_text("Idle") + + # Wait for status to update (uses AJAX polling every 2s) + page.wait_for_timeout(3000) + + # Status should change to "Running" + expect(page.locator("#status-indicator")).to_contain_text("Running") + + # Wait for it to finish (mock takes 5 seconds, add buffer for polling) + page.wait_for_timeout(8000) + + # Status should return to Idle - allow longer timeout for polling + expect(page.locator("#status-indicator")).to_contain_text("Idle", timeout=15000) def test_update_prompt(page: Page, server: str): auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - - # Find prompt textarea - textarea = page.locator("textarea[name='content']").first + + # Navigate to prompt section + page.click("[data-section='prompt']") + page.wait_for_selector("#prompt.active") + + # Find prompt textarea - specifically in the prompt section + textarea = page.locator("#prompt textarea[name='content']") original_content = textarea.input_value() - + new_content = original_content + "\n# Test Comment" textarea.fill(new_content) - - # Click update prompt - page.click("button:has-text('Save Prompt')") - + + # Click save prompt + page.click("#prompt button:has-text('Save Prompt')") + # Verify it updated page.reload() - expect(page.locator("textarea[name='content']").first).to_have_value(new_content) + page.click("[data-section='prompt']") + page.wait_for_selector("#prompt.active") + expect(page.locator("#prompt textarea[name='content']")).to_have_value(new_content) def test_update_settings(page: Page, server: str): auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - - # Add a new setting - page.fill("input[name='new_env_key']", "TEST_SETTING") - page.fill("input[name='new_env_value']", "test_value") - - page.click("button:has-text('Save Settings')") - + + # Navigate to settings section + page.click("[data-section='settings']") + page.wait_for_selector("#settings.active") + + # Wait for Choices.js to initialize + page.wait_for_timeout(1000) + + # Add a new setting using Choices.js select + # Click on the outer .choices wrapper (first match only) + key_choices = page.locator("#settings select[name='new_env_key']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first + key_choices.click() + page.keyboard.type("GITHUB_TOKEN") + page.keyboard.press("Enter") + + # For new_env_value + value_choices = page.locator("#settings select[name='new_env_value']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first + value_choices.click() + page.keyboard.type("DEBUG") + page.keyboard.press("Enter") + + page.click("#settings button:has-text('Save Settings')") + # Verify it appeared in the table page.reload() - expect(page.locator("input[name='env_TEST_SETTING']")).to_have_value("test_value") + page.click("[data-section='settings']") + page.wait_for_selector("#settings.active") + expect(page.locator("#settings code:has-text('GITHUB_TOKEN')")).to_be_visible() -def test_all_text_inputs_have_autocomplete(page: Page, server: str): +def test_navigation_sections(page: Page, server: str): + """Test that sidebar navigation works correctly""" auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - - # Wait for the workflow builder to render - page.wait_for_selector("#workflow-builder") - - # Select all text inputs - inputs = page.locator("input[type='text']") - - count = inputs.count() - assert count > 0, "No text inputs found on the page" - - for i in range(count): - input_element = inputs.nth(i) - # Check if the 'list' attribute is present and not empty - list_attr = input_element.get_attribute("list") - input_name = input_element.get_attribute("name") or input_element.get_attribute("placeholder") or f"index {i}" - assert list_attr, f"Input '{input_name}' does not have a 'list' attribute for autocomplete" - - # Check if the corresponding datalist exists - datalist = page.locator(f"datalist#{list_attr}") - expect(datalist).to_be_attached(), f"Datalist '{list_attr}' for input '{input_name}' is missing" + + # Dashboard should be active by default + expect(page.locator("#dashboard")).to_have_class(re.compile(r"active")) + + # Navigate to each section + sections = ["workflow", "prompt", "settings", "translations"] + for section in sections: + page.click(f"[data-section='{section}']") + page.wait_for_timeout(100) + expect(page.locator(f"#{section}")).to_have_class(re.compile(r"active")) + # Previous section should no longer be active + expect(page.locator("#dashboard")).not_to_have_class(re.compile(r"active")) + +def test_theme_toggle(page: Page, server: str): + """Test dark mode toggle functionality""" + auth_url = server.replace("http://", "http://testuser:testpass@") + page.goto(auth_url) + + # Get initial theme + html = page.locator("html") + initial_theme = html.get_attribute("data-theme") + + # Click toggle + page.click("[data-theme-toggle]") + page.wait_for_timeout(100) + + # Theme should have changed + new_theme = html.get_attribute("data-theme") + assert new_theme != initial_theme, f"Theme did not change from {initial_theme}" + + # Toggle back + page.click("[data-theme-toggle]") + page.wait_for_timeout(100) + final_theme = html.get_attribute("data-theme") + assert final_theme == initial_theme, f"Theme should return to {initial_theme}" + +def test_choices_dropdowns_exist(page: Page, server: str): + """Test that Choices.js dropdowns are initialized""" + auth_url = server.replace("http://", "http://testuser:testpass@") + page.goto(auth_url) + + # Navigate to translations to find language dropdown + page.click("[data-section='translations']") + page.wait_for_selector("#translations.active") + + # Wait for Choices.js to initialize + page.wait_for_timeout(1000) + + # Check that data-choices elements have been enhanced + choices_elements = page.locator("#translations [data-choices]") + count = choices_elements.count() + assert count > 0, "No Choices.js elements found in translations section" + + # Verify Choices.js wrapper exists - use CSS selector for the outer wrapper + choices_wrapper = page.locator("#translations div.choices[data-type='select-one']").first + expect(choices_wrapper).to_be_visible() def test_autocomplete_values_from_json(page: Page, server: str): + """Test that dropdown options are populated from metadata.json""" # Load metadata.json metadata_path = os.path.join(os.path.dirname(__file__), "../../src/autometabuilder/metadata.json") with open(metadata_path, 'r') as f: metadata = json.load(f) - + auth_url = server.replace("http://", "http://testuser:testpass@") page.goto(auth_url) - - # Verify lang-suggestions - for lang in metadata["suggestions"]["languages"]: - expect(page.locator(f"#lang-suggestions option[value='{lang}']").first).to_be_attached() - - # Verify env-key-suggestions - for key in metadata["suggestions"]["env_keys"]: - expect(page.locator(f"#env-key-suggestions option[value='{key}']").first).to_be_attached() - - # Verify env-value-suggestions - for val in metadata["suggestions"]["env_values"]: - expect(page.locator(f"#env-value-suggestions option[value='{val}']").first).to_be_attached() - - # Verify task-name-suggestions - for name in metadata["suggestions"]["task_names"]: - expect(page.locator(f"#task-name-suggestions option[value='{name}']").first).to_be_attached() - # Verify workflow builder step suggestions - # We need to add a task and a step first - # This might be complex but let's at least check if allSuggestions was populated - # and used in some datalist. - # The existing test_all_text_inputs_have_autocomplete already checks if datalists are attached. + # Navigate to translations section to check language options + page.click("[data-section='translations']") + page.wait_for_selector("#translations.active") + + # Verify language options in select + lang_select = page.locator("#translations select[name='lang']") + for lang in metadata["suggestions"]["languages"]: + expect(lang_select.locator(f"option[value='{lang}']")).to_be_attached() + + # Navigate to settings to check env options + page.click("[data-section='settings']") + page.wait_for_selector("#settings.active") + + # Verify env key options + key_select = page.locator("#settings select[name='new_env_key']") + for key in metadata["suggestions"]["env_keys"]: + expect(key_select.locator(f"option[value='{key}']")).to_be_attached() + + # Verify env value options + value_select = page.locator("#settings select[name='new_env_value']") + for val in metadata["suggestions"]["env_values"]: + expect(value_select.locator(f"option[value='{val}']")).to_be_attached() + +def test_workflow_builder_renders(page: Page, server: str): + """Test that workflow builder initializes and renders""" + auth_url = server.replace("http://", "http://testuser:testpass@") + page.goto(auth_url) + + # Navigate to workflow section + page.click("[data-section='workflow']") + page.wait_for_selector("#workflow.active") + + # Wait for workflow builder to render + page.wait_for_selector("#workflow-builder") + + # Should have at least the "Add Task" button + expect(page.locator("#workflow-builder button:has-text('Add Task')")).to_be_visible() + + # Toggle raw JSON should work + page.click("#workflow button:has-text('Toggle Raw JSON')") + expect(page.locator("#workflow-content")).to_be_visible() + + page.click("#workflow button:has-text('Toggle Raw JSON')") + expect(page.locator("#workflow-content")).not_to_be_visible()