feat: improve dev preview with target management, IPv4 fix, and UI redesign

- Replace DevTargetSetter with DevTargetManager interface supporting
  register/unregister/activate/deactivate lifecycle for multiple targets
- Fix iframe blank bug by rewriting localhost to 127.0.0.1 (IPv4) in proxy
- Add proxy ErrorHandler returning styled HTML error page on 502
- Remove free-form URL input; only registered targets can be activated via API
- Redesign Dev tab UI: compact header with status dot, tappable target cards
  with tap feedback, enlarged iframe (50vh→70vh), CSS classes instead of
  inline styles
- Add comprehensive test coverage (~90 new tests) for registration, activation,
  proxy, API, concurrency, SSE events, and E2E lifecycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 20:20:18 +09:00
parent 4e52b1cb75
commit 1c5e5f9eed
5 changed files with 2033 additions and 167 deletions

View file

@ -9,10 +9,12 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -159,10 +161,21 @@ func (n *StateNotifier) Notify() {
}
}
// DevTargetSetter allows tools to control the dev proxy target.
type DevTargetSetter interface {
SetDevTarget(target string) error
// DevTarget represents a registered dev server target.
type DevTarget struct {
ID string `json:"id"`
Name string `json:"name"` // display name (e.g. "frontend")
Target string `json:"target"` // URL (e.g. "http://localhost:3000")
}
// DevTargetManager allows tools to register, activate, and deactivate dev proxy targets.
type DevTargetManager interface {
RegisterDevTarget(name, target string) (id string, err error)
UnregisterDevTarget(id string) error
ActivateDevTarget(id string) error
DeactivateDevTarget() error
GetDevTarget() string
ListDevTargets() []DevTarget
}
// Handler serves the Mini App HTML and API endpoints.
@ -172,48 +185,130 @@ type Handler struct {
botToken string
notifier *StateNotifier
devMu sync.RWMutex
devTarget *url.URL
devProxy *httputil.ReverseProxy
devMu sync.RWMutex
devTarget *url.URL
devProxy *httputil.ReverseProxy
devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
devNextID int
devActiveID string
}
// NewHandler creates a new Mini App handler.
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler {
return &Handler{
provider: provider,
sender: sender,
botToken: botToken,
notifier: notifier,
provider: provider,
sender: sender,
botToken: botToken,
notifier: notifier,
devTargets: make(map[string]*DevTarget),
}
}
// SetDevTarget sets the reverse proxy target URL. Only localhost targets are allowed.
// Pass an empty string to disable the proxy.
func (h *Handler) SetDevTarget(target string) error {
// validateLocalhostURL parses and validates that a URL targets localhost.
func validateLocalhostURL(target string) (*url.URL, error) {
u, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
host := u.Hostname()
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return nil, fmt.Errorf("only localhost targets are allowed, got %q", host)
}
return u, nil
}
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
if _, err := validateLocalhostURL(target); err != nil {
return "", err
}
h.devMu.Lock()
defer h.devMu.Unlock()
if target == "" {
h.devNextID++
id := strconv.Itoa(h.devNextID)
h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target}
if h.notifier != nil {
h.notifier.Notify()
}
return id, nil
}
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
func (h *Handler) UnregisterDevTarget(id string) error {
h.devMu.Lock()
defer h.devMu.Unlock()
if _, ok := h.devTargets[id]; !ok {
return fmt.Errorf("target %q not found", id)
}
delete(h.devTargets, id)
if h.devActiveID == id {
h.devActiveID = ""
h.devTarget = nil
h.devProxy = nil
if h.notifier != nil {
h.notifier.Notify()
}
return nil
}
if h.notifier != nil {
h.notifier.Notify()
}
return nil
}
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
func (h *Handler) ActivateDevTarget(id string) error {
h.devMu.Lock()
defer h.devMu.Unlock()
dt, ok := h.devTargets[id]
if !ok {
return fmt.Errorf("target %q not found", id)
}
u, err := url.Parse(target)
u, err := url.Parse(dt.Target)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
host := u.Hostname()
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return fmt.Errorf("only localhost targets are allowed, got %q", host)
// Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems
// where localhost resolves to [::1] but the dev server only listens on IPv4.
if u.Hostname() == "localhost" {
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
}
proxy := httputil.NewSingleHostReverseProxy(u)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `<!DOCTYPE html>
<html><head><style>
body{background:#1c1c1e;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:32px}
h2{margin:0 0 12px;font-size:20px;font-weight:600}
p{color:#8e8e93;font-size:14px;margin:0}
</style></head><body><div class="box"><h2>Cannot connect</h2><p>%s</p><p style="margin-top:8px;font-size:12px">Target: %s</p></div></body></html>`,
escapeHTMLString(err.Error()), escapeHTMLString(dt.Target))
}
h.devTarget = u
h.devProxy = httputil.NewSingleHostReverseProxy(u)
h.devProxy = proxy
h.devActiveID = id
if h.notifier != nil {
h.notifier.Notify()
}
return nil
}
// DeactivateDevTarget disables the reverse proxy without removing registrations.
func (h *Handler) DeactivateDevTarget() error {
h.devMu.Lock()
defer h.devMu.Unlock()
h.devActiveID = ""
h.devTarget = nil
h.devProxy = nil
if h.notifier != nil {
h.notifier.Notify()
}
@ -230,6 +325,29 @@ func (h *Handler) GetDevTarget() string {
return h.devTarget.String()
}
// ListDevTargets returns all registered dev targets.
func (h *Handler) ListDevTargets() []DevTarget {
h.devMu.RLock()
defer h.devMu.RUnlock()
targets := make([]DevTarget, 0, len(h.devTargets))
for _, dt := range h.devTargets {
targets = append(targets, *dt)
}
// Sort by ID for stable order
sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
return targets
}
// escapeHTMLString escapes HTML special characters in a string.
func escapeHTMLString(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
// RegisterRoutes registers Mini App routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/miniapp", h.serveIndex)
@ -345,11 +463,7 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
target := h.GetDevTarget()
writeJSON(w, map[string]any{
"active": target != "",
"target": target,
})
writeJSON(w, h.devStatus())
case http.MethodPost:
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
@ -357,21 +471,33 @@ func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Target string `json:"target"`
Action string `json:"action"`
ID string `json:"id"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if err := h.SetDevTarget(req.Target); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
switch req.Action {
case "activate":
if req.ID == "" {
writeJSON(w, map[string]any{"error": "id is required"})
return
}
if err := h.ActivateDevTarget(req.ID); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
case "deactivate":
if err := h.DeactivateDevTarget(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
default:
writeJSON(w, map[string]any{"error": "unknown action"})
return
}
target := h.GetDevTarget()
writeJSON(w, map[string]any{
"active": target != "",
"target": target,
})
writeJSON(w, h.devStatus())
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
@ -462,10 +588,26 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) devStatus() map[string]any {
target := h.GetDevTarget()
h.devMu.RLock()
defer h.devMu.RUnlock()
active := h.devTarget != nil
target := ""
if h.devTarget != nil {
target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite
}
targets := make([]DevTarget, 0, len(h.devTargets))
for _, dt := range h.devTargets {
targets = append(targets, *dt)
}
sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
return map[string]any{
"active": target != "",
"target": target,
"active": active,
"active_id": h.devActiveID,
"target": target,
"targets": targets,
}
}

File diff suppressed because it is too large Load diff

View file

@ -633,6 +633,58 @@
}
.git-back-btn:active { opacity: 0.6; }
/* Dev header */
.dev-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
margin-bottom: 8px;
}
.dev-header-title { font-weight: 600; font-size: 15px; }
.dev-header-target {
color: var(--hint);
font-size: 13px;
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dev target cards */
.dev-target-item {
padding: 12px 14px;
margin-bottom: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.dev-target-item:active { transform: scale(0.98); }
.dev-target-item.active {
border-color: var(--btn);
box-shadow: 0 2px 8px var(--glass-shadow);
}
.dev-target-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--hint);
}
.dev-target-dot.on { background: var(--done); }
.dev-target-name { font-weight: 600; font-size: 14px; }
.dev-target-url {
color: var(--hint);
font-size: 13px;
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
@ -688,24 +740,15 @@
</div>
<div id="dev" class="panel">
<div id="dev-status" class="card glass">
<div class="card-title">Dev Preview</div>
<div class="card-value" id="dev-status-label">Inactive</div>
<div id="dev-target-display" style="color:var(--hint);margin-top:4px;font-size:13px"></div>
<div id="dev-header" class="dev-header">
<span id="dev-dot" class="dev-target-dot"></span>
<span class="dev-header-title">Dev Preview</span>
<span id="dev-header-target" class="dev-header-target"></span>
</div>
<div class="card glass">
<div class="card-title">Target URL</div>
<div style="display:flex;gap:8px;margin-top:8px">
<input id="dev-target-input" class="send-input glass glass-interactive" placeholder="http://localhost:3000" style="flex:1">
<button class="send-btn" id="dev-set-btn" onclick="setDevTarget()">Set</button>
</div>
<div style="margin-top:8px">
<button class="send-btn" id="dev-stop-btn" onclick="clearDevTarget()" style="background:var(--hint);width:100%">Stop</button>
</div>
</div>
<div id="dev-iframe-wrap" class="hidden" style="margin-top:12px">
<div id="dev-targets-list"></div>
<div id="dev-iframe-wrap" class="hidden" style="margin-top:8px">
<div class="card glass" style="padding:0;overflow:hidden">
<iframe id="dev-iframe" src="" style="width:100%;height:50vh;border:none;border-radius:16px"></iframe>
<iframe id="dev-iframe" src="" style="width:100%;height:70vh;border:none;border-radius:16px"></iframe>
</div>
</div>
</div>
@ -1264,64 +1307,75 @@ function renderGitDetail(repo) {
}
// ── Dev tab ──
var devActiveId = '';
function renderDevFromData(data) {
var label = document.getElementById('dev-status-label');
var display = document.getElementById('dev-target-display');
var dot = document.getElementById('dev-dot');
var headerTarget = document.getElementById('dev-header-target');
var targetsList = document.getElementById('dev-targets-list');
var iframeWrap = document.getElementById('dev-iframe-wrap');
var iframe = document.getElementById('dev-iframe');
var targets = data.targets || [];
devActiveId = data.active_id || '';
if (data.active) {
label.textContent = 'Active';
label.style.color = 'var(--done)';
display.textContent = data.target;
dot.classList.add('on');
headerTarget.textContent = data.target ? data.target.replace(/^https?:\/\//, '') : '';
iframeWrap.classList.remove('hidden');
var iframeSrc = location.origin + '/miniapp/dev/';
if (iframe.src !== iframeSrc) iframe.src = iframeSrc;
} else {
label.textContent = 'Inactive';
label.style.color = '';
display.textContent = '';
dot.classList.remove('on');
headerTarget.textContent = '';
iframeWrap.classList.add('hidden');
iframe.src = '';
}
if (targets.length === 0) {
targetsList.innerHTML = '<div class="empty-state">No targets registered.<br>Ask the agent to start a dev server.</div>';
return;
}
targetsList.innerHTML = targets.map(function(t) {
var isActive = t.id === devActiveId;
var activeClass = isActive ? ' active' : '';
var dotClass = isActive ? ' on' : '';
var displayUrl = t.target.replace(/^https?:\/\//, '');
return '<div class="dev-target-item glass glass-interactive' + activeClass + '" data-dev-id="' + escapeAttr(t.id) + '">' +
'<span class="dev-target-dot' + dotClass + '"></span>' +
'<span class="dev-target-name">' + escapeHtml(t.name) + '</span>' +
'<span class="dev-target-url">' + escapeHtml(displayUrl) + '</span>' +
'</div>';
}).join('');
}
// Delegate click on target cards
document.getElementById('dev-targets-list').addEventListener('click', function(e) {
var card = e.target.closest('[data-dev-id]');
if (!card) return;
postDevAction(card.dataset.devId);
});
async function postDevAction(id) {
var action = (id === devActiveId) ? 'deactivate' : 'activate';
var body = action === 'activate' ? { action: 'activate', id: id } : { action: 'deactivate' };
try {
var res = await fetch(API_BASE + '/miniapp/api/dev?initData=' + encodeURIComponent(initData), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var data = await res.json();
if (!data.error) renderDevFromData(data);
} catch(e) {}
}
function loadDev() {
apiFetch('/miniapp/api/dev').then(renderDevFromData).catch(function() {});
}
async function setDevTarget() {
var input = document.getElementById('dev-target-input');
var btn = document.getElementById('dev-set-btn');
var target = input.value.trim();
if (!target) return;
try {
var res = await fetch(API_BASE + '/miniapp/api/dev?initData=' + encodeURIComponent(initData), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: target }),
});
var data = await res.json();
if (data.error) return;
renderDevFromData(data);
flashSent(btn);
} catch(e) {}
}
async function clearDevTarget() {
var btn = document.getElementById('dev-stop-btn');
try {
var res = await fetch(API_BASE + '/miniapp/api/dev?initData=' + encodeURIComponent(initData), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: '' }),
});
var data = await res.json();
renderDevFromData(data);
flashSent(btn);
} catch(e) {}
}
// ── SSE real-time updates ──
var eventSource = null;

View file

@ -3,24 +3,26 @@ package tools
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/sipeed/picoclaw/pkg/miniapp"
)
// DevPreviewTool allows the agent to control the Mini App dev reverse proxy.
type DevPreviewTool struct {
setter miniapp.DevTargetSetter
manager miniapp.DevTargetManager
}
// NewDevPreviewTool creates a new DevPreviewTool.
func NewDevPreviewTool(setter miniapp.DevTargetSetter) *DevPreviewTool {
return &DevPreviewTool{setter: setter}
func NewDevPreviewTool(manager miniapp.DevTargetManager) *DevPreviewTool {
return &DevPreviewTool{manager: manager}
}
func (t *DevPreviewTool) Name() string { return "dev_preview" }
func (t *DevPreviewTool) Description() string {
return "Control the Mini App dev preview proxy. Use 'start' to expose a local dev server (localhost only) through the Mini App, 'stop' to disable it, or 'status' to check the current state."
return "Control the Mini App dev preview proxy. Use 'start' to register and activate a local dev server (localhost only), 'stop' to deactivate the proxy, 'unregister' to remove a registered target, or 'status' to check all registered targets and active state."
}
func (t *DevPreviewTool) Parameters() map[string]any {
@ -29,13 +31,21 @@ func (t *DevPreviewTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"start", "stop", "status"},
"description": "Action to perform: start (set proxy target), stop (disable proxy), status (check current target).",
"enum": []string{"start", "stop", "unregister", "status"},
"description": "Action to perform: start (register + activate target), stop (deactivate proxy), unregister (remove a registered target), status (list all targets).",
},
"target": map[string]any{
"type": "string",
"description": "Target URL for the dev server (e.g. http://localhost:3000). Required for 'start' action. Must be a localhost URL.",
},
"name": map[string]any{
"type": "string",
"description": "Display name for the target (e.g. 'frontend'). Optional for 'start' action; auto-generated from host:port if omitted.",
},
"id": map[string]any{
"type": "string",
"description": "Target ID. Required for 'unregister' action.",
},
},
"required": []string{"action"},
}
@ -53,25 +63,71 @@ func (t *DevPreviewTool) Execute(ctx context.Context, args map[string]any) *Tool
if target == "" {
return ErrorResult("target is required for start action")
}
if err := t.setter.SetDevTarget(target); err != nil {
return ErrorResult(fmt.Sprintf("failed to set dev target: %v", err))
name, _ := args["name"].(string)
if name == "" {
name = inferName(target)
}
return SilentResult(fmt.Sprintf("Dev preview started. Target: %s\nUsers can view it in the Mini App Dev tab.", target))
id, err := t.manager.RegisterDevTarget(name, target)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to register dev target: %v", err))
}
if err := t.manager.ActivateDevTarget(id); err != nil {
return ErrorResult(fmt.Sprintf("failed to activate dev target: %v", err))
}
return SilentResult(fmt.Sprintf("Dev preview started (id=%s, name=%s). Target: %s\nUsers can view it in the Mini App Dev tab.", id, name, target))
case "stop":
if err := t.setter.SetDevTarget(""); err != nil {
if err := t.manager.DeactivateDevTarget(); err != nil {
return ErrorResult(fmt.Sprintf("failed to stop dev preview: %v", err))
}
return SilentResult("Dev preview stopped.")
case "status":
target := t.setter.GetDevTarget()
if target == "" {
return SilentResult("Dev preview is not active.")
case "unregister":
id, _ := args["id"].(string)
if id == "" {
return ErrorResult("id is required for unregister action")
}
return SilentResult(fmt.Sprintf("Dev preview is active. Target: %s", target))
if err := t.manager.UnregisterDevTarget(id); err != nil {
return ErrorResult(fmt.Sprintf("failed to unregister target: %v", err))
}
return SilentResult(fmt.Sprintf("Dev target %s unregistered.", id))
case "status":
targets := t.manager.ListDevTargets()
active := t.manager.GetDevTarget()
if len(targets) == 0 {
if active == "" {
return SilentResult("Dev preview is not active. No targets registered.")
}
return SilentResult(fmt.Sprintf("Dev preview is active. Target: %s\nNo registered targets.", active))
}
var sb strings.Builder
if active != "" {
sb.WriteString(fmt.Sprintf("Dev preview is active. Target: %s\n", active))
} else {
sb.WriteString("Dev preview is not active.\n")
}
sb.WriteString("Registered targets:\n")
for _, dt := range targets {
sb.WriteString(fmt.Sprintf(" [%s] %s → %s\n", dt.ID, dt.Name, dt.Target))
}
return SilentResult(sb.String())
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}
// inferName generates a display name from a target URL (e.g. "localhost:3000").
func inferName(target string) string {
u, err := url.Parse(target)
if err != nil {
return target
}
host := u.Hostname()
port := u.Port()
if port != "" {
return host + ":" + port
}
return host
}

View file

@ -5,29 +5,103 @@ import (
"fmt"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/miniapp"
)
// mockDevTargetSetter implements miniapp.DevTargetSetter for testing.
type mockDevTargetSetter struct {
target string
err error
// mockDevTargetManager implements miniapp.DevTargetManager for testing.
type mockDevTargetManager struct {
targets map[string]*miniapp.DevTarget
nextID int
activeID string
active string // active target URL
regErr error
}
func (m *mockDevTargetSetter) SetDevTarget(target string) error {
if m.err != nil {
return m.err
func newMockManager() *mockDevTargetManager {
return &mockDevTargetManager{targets: make(map[string]*miniapp.DevTarget)}
}
func (m *mockDevTargetManager) RegisterDevTarget(name, target string) (string, error) {
if m.regErr != nil {
return "", m.regErr
}
m.nextID++
id := fmt.Sprintf("%d", m.nextID)
m.targets[id] = &miniapp.DevTarget{ID: id, Name: name, Target: target}
return id, nil
}
func (m *mockDevTargetManager) UnregisterDevTarget(id string) error {
if _, ok := m.targets[id]; !ok {
return fmt.Errorf("target %q not found", id)
}
delete(m.targets, id)
if m.activeID == id {
m.activeID = ""
m.active = ""
}
m.target = target
return nil
}
func (m *mockDevTargetSetter) GetDevTarget() string {
return m.target
func (m *mockDevTargetManager) ActivateDevTarget(id string) error {
dt, ok := m.targets[id]
if !ok {
return fmt.Errorf("target %q not found", id)
}
m.activeID = id
m.active = dt.Target
return nil
}
func (m *mockDevTargetManager) DeactivateDevTarget() error {
m.activeID = ""
m.active = ""
return nil
}
func (m *mockDevTargetManager) GetDevTarget() string {
return m.active
}
func (m *mockDevTargetManager) ListDevTargets() []miniapp.DevTarget {
var out []miniapp.DevTarget
for _, dt := range m.targets {
out = append(out, *dt)
}
return out
}
func TestDevPreviewTool_Start(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:3000",
"name": "frontend",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(mgr.targets) != 1 {
t.Errorf("expected 1 registered target, got %d", len(mgr.targets))
}
if mgr.active != "http://localhost:3000" {
t.Errorf("expected active target http://localhost:3000, got %q", mgr.active)
}
if !strings.Contains(result.ForLLM, "started") {
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "frontend") {
t.Errorf("expected result to contain 'frontend', got %q", result.ForLLM)
}
}
func TestDevPreviewTool_StartAutoName(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
@ -37,17 +111,17 @@ func TestDevPreviewTool_Start(t *testing.T) {
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if setter.target != "http://localhost:3000" {
t.Errorf("expected target http://localhost:3000, got %q", setter.target)
}
if !strings.Contains(result.ForLLM, "started") {
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
// Auto-generated name should be "localhost:3000"
for _, dt := range mgr.targets {
if dt.Name != "localhost:3000" {
t.Errorf("expected auto-name 'localhost:3000', got %q", dt.Name)
}
}
}
func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
@ -59,8 +133,9 @@ func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
}
func TestDevPreviewTool_StartError(t *testing.T) {
setter := &mockDevTargetSetter{err: fmt.Errorf("only localhost")}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
mgr.regErr = fmt.Errorf("only localhost")
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
@ -68,13 +143,14 @@ func TestDevPreviewTool_StartError(t *testing.T) {
})
if !result.IsError {
t.Error("expected error for setter failure")
t.Error("expected error for registration failure")
}
}
func TestDevPreviewTool_Stop(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:3000"}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
mgr.active = "http://localhost:3000"
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "stop",
@ -83,14 +159,65 @@ func TestDevPreviewTool_Stop(t *testing.T) {
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if setter.target != "" {
t.Errorf("expected empty target after stop, got %q", setter.target)
if mgr.active != "" {
t.Errorf("expected empty active target after stop, got %q", mgr.active)
}
}
func TestDevPreviewTool_Unregister(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
// Register a target first
id, _ := mgr.RegisterDevTarget("frontend", "http://localhost:3000")
result := tool.Execute(context.Background(), map[string]any{
"action": "unregister",
"id": id,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(mgr.targets) != 0 {
t.Errorf("expected 0 targets after unregister, got %d", len(mgr.targets))
}
}
func TestDevPreviewTool_UnregisterMissingID(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "unregister",
})
if !result.IsError {
t.Error("expected error for missing id")
}
}
func TestDevPreviewTool_UnregisterNotFound(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "unregister",
"id": "999",
})
if !result.IsError {
t.Error("expected error for non-existent target")
}
}
func TestDevPreviewTool_Status(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:8080"}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
mgr.RegisterDevTarget("api", "http://localhost:8080")
mgr.RegisterDevTarget("frontend", "http://localhost:3000")
mgr.active = "http://localhost:8080"
result := tool.Execute(context.Background(), map[string]any{
"action": "status",
@ -105,11 +232,17 @@ func TestDevPreviewTool_Status(t *testing.T) {
if !strings.Contains(result.ForLLM, "http://localhost:8080") {
t.Errorf("expected target URL in result, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "api") {
t.Errorf("expected 'api' in result, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "frontend") {
t.Errorf("expected 'frontend' in result, got %q", result.ForLLM)
}
}
func TestDevPreviewTool_StatusInactive(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "status",
@ -124,8 +257,8 @@ func TestDevPreviewTool_StatusInactive(t *testing.T) {
}
func TestDevPreviewTool_UnknownAction(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "restart",
@ -137,8 +270,8 @@ func TestDevPreviewTool_UnknownAction(t *testing.T) {
}
func TestDevPreviewTool_MissingAction(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{})
@ -148,8 +281,8 @@ func TestDevPreviewTool_MissingAction(t *testing.T) {
}
func TestDevPreviewTool_NameAndSchema(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
if tool.Name() != "dev_preview" {
t.Errorf("expected name dev_preview, got %q", tool.Name())
@ -162,3 +295,259 @@ func TestDevPreviewTool_NameAndSchema(t *testing.T) {
t.Fatal("expected non-nil parameters")
}
}
// ── Additional edge-case tests ──
func TestDevPreviewTool_StartMultipleTargets(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
r1 := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:8080",
"name": "api",
})
r2 := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:3000",
"name": "frontend",
})
if r1.IsError || r2.IsError {
t.Fatalf("expected both starts to succeed, got err1=%v err2=%v", r1.IsError, r2.IsError)
}
if len(mgr.targets) != 2 {
t.Errorf("expected 2 registered targets, got %d", len(mgr.targets))
}
// The second start should make the frontend active
if mgr.active != "http://localhost:3000" {
t.Errorf("expected last started target to be active, got %q", mgr.active)
}
}
func TestDevPreviewTool_StopPreservesRegistrations(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:3000",
"name": "frontend",
})
result := tool.Execute(context.Background(), map[string]any{
"action": "stop",
})
if result.IsError {
t.Fatalf("stop failed: %s", result.ForLLM)
}
// Registration should still be there
if len(mgr.targets) != 1 {
t.Errorf("expected 1 registered target after stop, got %d", len(mgr.targets))
}
// But active should be cleared
if mgr.active != "" {
t.Errorf("expected inactive after stop, got %q", mgr.active)
}
}
func TestDevPreviewTool_StatusWithTargetsButInactive(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
mgr.RegisterDevTarget("api", "http://localhost:8080")
// active remains empty
result := tool.Execute(context.Background(), map[string]any{
"action": "status",
})
if result.IsError {
t.Fatalf("status failed: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "not active") {
t.Errorf("expected 'not active' in status, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "api") {
t.Errorf("expected 'api' listed in status, got %q", result.ForLLM)
}
}
func TestDevPreviewTool_ResultIsSilent(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
cases := []struct {
name string
args map[string]any
}{
{"start", map[string]any{"action": "start", "target": "http://localhost:3000"}},
{"stop", map[string]any{"action": "stop"}},
{"status", map[string]any{"action": "status"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := tool.Execute(context.Background(), tc.args)
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if result.Silent != true {
t.Errorf("expected SilentResult (IsSilent=true), got IsSilent=%v", result.Silent)
}
})
}
}
func TestDevPreviewTool_ActionTypeNotString(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": 123,
})
if !result.IsError {
t.Error("expected error for non-string action")
}
}
func TestDevPreviewTool_InferName(t *testing.T) {
cases := []struct {
target string
want string
}{
{"http://localhost:3000", "localhost:3000"},
{"http://localhost:8080", "localhost:8080"},
{"http://127.0.0.1:9000", "127.0.0.1:9000"},
{"http://localhost", "localhost"},
{"http://[::1]:5000", "::1:5000"},
{"not-a-url", ""}, // url.Parse succeeds but Hostname() is empty
}
for _, tc := range cases {
got := inferName(tc.target)
if got != tc.want {
t.Errorf("inferName(%q) = %q, want %q", tc.target, got, tc.want)
}
}
}
func TestDevPreviewTool_StartEmptyName(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
// Explicitly pass empty name — should auto-infer
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:5000",
"name": "",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
for _, dt := range mgr.targets {
if dt.Name != "localhost:5000" {
t.Errorf("expected auto-name 'localhost:5000', got %q", dt.Name)
}
}
}
func TestDevPreviewTool_UnregisterActiveTarget(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
// Register and activate
tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:3000",
"name": "frontend",
})
// Find the registered ID
var id string
for k := range mgr.targets {
id = k
}
result := tool.Execute(context.Background(), map[string]any{
"action": "unregister",
"id": id,
})
if result.IsError {
t.Fatalf("unregister failed: %s", result.ForLLM)
}
if len(mgr.targets) != 0 {
t.Errorf("expected 0 targets, got %d", len(mgr.targets))
}
if mgr.active != "" {
t.Errorf("expected no active target, got %q", mgr.active)
}
}
func TestDevPreviewTool_StartTargetEmptyString(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "",
})
if !result.IsError {
t.Error("expected error for empty target string")
}
}
func TestDevPreviewTool_StatusActiveNoTargets(t *testing.T) {
// Edge case: active proxy but no registered targets (shouldn't normally happen)
mgr := newMockManager()
mgr.active = "http://localhost:9999" // active but targets map is empty
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{
"action": "status",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "active") {
t.Errorf("expected 'active' in result, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "http://localhost:9999") {
t.Errorf("expected target URL in result, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "No registered targets") {
t.Errorf("expected 'No registered targets' in result, got %q", result.ForLLM)
}
}
func TestDevPreviewTool_StatusOutputFormat(t *testing.T) {
mgr := newMockManager()
tool := NewDevPreviewTool(mgr)
id1, _ := mgr.RegisterDevTarget("api", "http://localhost:8080")
mgr.RegisterDevTarget("frontend", "http://localhost:3000")
mgr.ActivateDevTarget(id1)
result := tool.Execute(context.Background(), map[string]any{
"action": "status",
})
if result.IsError {
t.Fatalf("status failed: %s", result.ForLLM)
}
// Should contain IDs in bracket format
if !strings.Contains(result.ForLLM, "["+id1+"]") {
t.Errorf("expected [%s] in output, got %q", id1, result.ForLLM)
}
// Should contain the arrow
if !strings.Contains(result.ForLLM, "→") {
t.Errorf("expected arrow in output, got %q", result.ForLLM)
}
// Should contain "Registered targets:"
if !strings.Contains(result.ForLLM, "Registered targets:") {
t.Errorf("expected 'Registered targets:' header, got %q", result.ForLLM)
}
}