defmodule AiDebug.ValidationOrchestratorEnhanced do @moduledoc """ Enhanced validation orchestrator that provides detailed feedback, executes actual JavaScript, and captures annotated screenshots. """ use GenServer require Logger alias AiDebug.MCPHttpClient defstruct [:session_id, :url, :criteria, :callback_pid, :checks, :current_check_index, :results] # Client API def start_link(opts) do GenServer.start_link(__MODULE__, opts) end def start_validation(url, criteria, callback_pid) do {:ok, pid} = start_link(%{ url: url, criteria: criteria, callback_pid: callback_pid }) GenServer.cast(pid, :start) {:ok, generate_session_id()} end # Server Callbacks @impl true def init(opts) do {:ok, %__MODULE__{ session_id: generate_session_id(), url: opts.url, criteria: opts.criteria, callback_pid: opts.callback_pid, checks: parse_criteria_into_checks(opts.criteria), current_check_index: 0, results: [] }} end @impl true def handle_cast(:start, state) do # Start browser session case MCPHttpClient.call_tool("start_debug_session", %{url: state.url}) do {:ok, _} -> # Start executing checks Process.send_after(self(), :execute_next_check, 500) {:noreply, state} {:error, reason} -> send(state.callback_pid, {:validation_error, state.session_id, reason}) {:stop, :normal, state} end end @impl true def handle_info(:execute_next_check, state) do if state.current_check_index < length(state.checks) do check = Enum.at(state.checks, state.current_check_index) # Notify UI we're starting this check send(state.callback_pid, {:validation_update, %{ type: :checking, check: check }}) # Execute the check result = execute_check(check, state) # Capture screenshot with annotations capture_annotated_screenshot(check, result, state) # Update results new_state = %{state | results: [result | state.results], current_check_index: state.current_check_index + 1 } # Notify UI of result send(state.callback_pid, {:validation_update, %{ type: :check_complete, result: result }}) # Continue with next check Process.send_after(self(), :execute_next_check, 1000) {:noreply, new_state} else # All checks complete finalize_validation(state) {:stop, :normal, state} end end # Private functions defp parse_criteria_into_checks(criteria) do # Parse natural language criteria into executable checks criteria |> String.split(~r/[.\n]/) |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> Enum.with_index() |> Enum.map(fn {criterion, index} -> %{ id: "check_#{index}", description: criterion, code: generate_check_code(criterion), selector: extract_selector(criterion) } end) end defp generate_check_code(criterion) do cond do String.contains?(criterion, ["navigation", "nav", "menu"]) -> generate_navigation_check(criterion) String.contains?(criterion, ["form"]) -> generate_form_check(criterion) String.contains?(criterion, ["button", "Button"]) -> generate_button_check(criterion) String.contains?(criterion, ["link", "Link"]) -> generate_link_check(criterion) true -> generate_generic_check(criterion) end end defp generate_navigation_check(criterion) do expected_links = extract_quoted_values(criterion) """ // Check for navigation menu const nav = document.querySelector('nav, [role="navigation"], .navigation, .nav, #nav'); if (!nav) { return {found: false, message: "No navigation element found"}; } const links = Array.from(nav.querySelectorAll('a')).map(a => a.textContent.trim()); const expected = #{inspect(expected_links)}; const missing = expected.filter(exp => !links.some(link => link.toLowerCase().includes(exp.toLowerCase()))); return { found: true, hasAllLinks: missing.length === 0, links: links, missing: missing, message: missing.length === 0 ? "Found navigation with all required links" : "Navigation found but missing: " + missing.join(", ") }; """ end defp generate_form_check(criterion) do fields = extract_form_fields(criterion) """ // Check for form and fields const form = document.querySelector('form'); if (!form) { return {found: false, message: "No form element found"}; } const requiredFields = #{inspect(fields)}; const foundFields = {}; const missingFields = []; requiredFields.forEach(field => { const input = form.querySelector(`input[name="${field}"], input[type="${field}"], input[placeholder*="${field}"]`); if (input) { foundFields[field] = { type: input.type, required: input.required, placeholder: input.placeholder }; } else { missingFields.push(field); } }); return { found: true, hasAllFields: missingFields.length === 0, fields: foundFields, missing: missingFields, message: missingFields.length === 0 ? "Form found with all required fields" : "Form found but missing fields: " + missingFields.join(", ") }; """ end defp generate_button_check(criterion) do button_text = extract_quoted_values(criterion) |> List.first() """ // Check for button const buttons = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"]')); const searchText = "#{button_text || "submit"}".toLowerCase(); const found = buttons.find(btn => btn.textContent.toLowerCase().includes(searchText) || btn.value?.toLowerCase().includes(searchText) ); return { found: !!found, buttonText: found ? found.textContent || found.value : null, buttonType: found ? found.tagName.toLowerCase() : null, message: found ? `Found button: "${found.textContent || found.value}"` : `No button with text containing "${searchText}" found` }; """ end defp generate_link_check(criterion) do link_text = extract_quoted_values(criterion) |> List.first() """ // Check for link const links = Array.from(document.querySelectorAll('a')); const searchText = "#{link_text}".toLowerCase(); const found = links.find(link => link.textContent.toLowerCase().includes(searchText) ); return { found: !!found, linkText: found ? found.textContent.trim() : null, linkHref: found ? found.href : null, message: found ? `Found link: "${found.textContent.trim()}" → ${found.href}` : `No link with text containing "${searchText}" found` }; """ end defp generate_generic_check(criterion) do """ // Generic check for: #{criterion} // Look for any element containing this text const searchText = "#{String.downcase(criterion)}"; const allElements = Array.from(document.querySelectorAll('*')); const found = allElements.find(el => el.textContent.toLowerCase().includes(searchText) && el.children.length === 0 // Only leaf nodes ); return { found: !!found, element: found ? found.tagName.toLowerCase() : null, text: found ? found.textContent.trim() : null, message: found ? `Found "${found.textContent.trim()}" in <${found.tagName.toLowerCase()}>` : "Could not find element matching this criterion" }; """ end defp extract_quoted_values(text) do Regex.scan(~r/"([^"]+)"/, text) |> Enum.map(fn [_, value] -> value end) end defp extract_form_fields(criterion) do # Extract common form field names fields = [] fields = if String.contains?(criterion, ["name"]), do: ["name" | fields], else: fields fields = if String.contains?(criterion, ["email"]), do: ["email" | fields], else: fields fields = if String.contains?(criterion, ["message"]), do: ["message" | fields], else: fields fields = if String.contains?(criterion, ["phone"]), do: ["phone" | fields], else: fields fields = if String.contains?(criterion, ["password"]), do: ["password" | fields], else: fields # Also extract quoted values quoted = extract_quoted_values(criterion) Enum.uniq(fields ++ quoted) end defp extract_selector(criterion) do cond do String.contains?(criterion, ["form"]) -> "form" String.contains?(criterion, ["navigation", "nav"]) -> "nav" String.contains?(criterion, ["button"]) -> "button" String.contains?(criterion, ["link"]) -> "a" true -> nil end end defp execute_check(check, state) do # Show the code being executed send(state.callback_pid, {:validation_update, %{ type: :executing, code: check.code }}) # Execute the JavaScript case MCPHttpClient.call_tool("execute_javascript", %{code: check.code}) do {:ok, %{"content" => [%{"text" => result_json}]}} -> result = Jason.decode!(result_json) execution_result = result["execution"]["result"] %{ id: check.id, description: check.description, passed: execution_result["found"] && (execution_result["hasAllLinks"] || execution_result["hasAllFields"] || true), details: execution_result["message"], raw_result: execution_result } {:error, reason} -> %{ id: check.id, description: check.description, passed: false, details: "Error executing check: #{inspect(reason)}", raw_result: %{"error" => reason} } end end defp capture_annotated_screenshot(check, result, state) do # Create annotation based on result annotation_js = if check.selector && result.passed do """ // Highlight the validated element const element = document.querySelector('#{check.selector}'); if (element) { const rect = element.getBoundingClientRect(); const highlight = document.createElement('div'); highlight.style.position = 'fixed'; highlight.style.left = rect.left + 'px'; highlight.style.top = rect.top + 'px'; highlight.style.width = rect.width + 'px'; highlight.style.height = rect.height + 'px'; highlight.style.border = '3px solid #10b981'; highlight.style.backgroundColor = 'rgba(16, 185, 129, 0.1)'; highlight.style.pointerEvents = 'none'; highlight.style.zIndex = '10000'; document.body.appendChild(highlight); // Add label const label = document.createElement('div'); label.style.position = 'fixed'; label.style.left = rect.left + 'px'; label.style.top = (rect.top - 30) + 'px'; label.style.backgroundColor = '#10b981'; label.style.color = 'white'; label.style.padding = '4px 8px'; label.style.borderRadius = '4px'; label.style.fontSize = '12px'; label.style.fontWeight = 'bold'; label.style.zIndex = '10001'; label.textContent = '✓ ' + '#{String.slice(check.description, 0..30)}...'; document.body.appendChild(label); // Return cleanup function return function() { highlight.remove(); label.remove(); }; } """ else "// No annotation" end # Execute annotation MCPHttpClient.call_tool("execute_javascript", %{code: annotation_js}) # Wait a bit for annotation to render :timer.sleep(200) # Take screenshot case MCPHttpClient.call_tool("take_screenshot", %{}) do {:ok, %{"content" => [%{"text" => screenshot_json}]}} -> screenshot_data = Jason.decode!(screenshot_json) send(state.callback_pid, {:validation_update, %{ type: :screenshot, check_id: check.id, data: screenshot_data["path"] }}) _ -> :ok end # Clean up annotation MCPHttpClient.call_tool("execute_javascript", %{code: "if (window.cleanupAnnotation) window.cleanupAnnotation();"}) end defp finalize_validation(state) do report = %{ url: state.url, criteria: state.criteria, total_checks: length(state.checks), passed_checks: Enum.count(state.results, & &1.passed), failed_checks: Enum.count(state.results, & !&1.passed), results: Enum.reverse(state.results) } send(state.callback_pid, {:validation_complete, state.session_id, report}) end defp generate_session_id do :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) end end