Merge branch 'worktree-dev-preview'

This commit is contained in:
dj-oyu 2026-02-22 20:21:20 +09:00
commit 07c49583e8
5 changed files with 2033 additions and 167 deletions

View file

@ -9,10 +9,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -159,10 +161,21 @@ func (n *StateNotifier) Notify() {
} }
} }
// DevTargetSetter allows tools to control the dev proxy target. // DevTarget represents a registered dev server target.
type DevTargetSetter interface { type DevTarget struct {
SetDevTarget(target string) error 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 GetDevTarget() string
ListDevTargets() []DevTarget
} }
// Handler serves the Mini App HTML and API endpoints. // Handler serves the Mini App HTML and API endpoints.
@ -175,6 +188,9 @@ type Handler struct {
devMu sync.RWMutex devMu sync.RWMutex
devTarget *url.URL devTarget *url.URL
devProxy *httputil.ReverseProxy devProxy *httputil.ReverseProxy
devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
devNextID int
devActiveID string
} }
// NewHandler creates a new Mini App handler. // NewHandler creates a new Mini App handler.
@ -184,36 +200,115 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no
sender: sender, sender: sender,
botToken: botToken, botToken: botToken,
notifier: notifier, notifier: notifier,
devTargets: make(map[string]*DevTarget),
} }
} }
// SetDevTarget sets the reverse proxy target URL. Only localhost targets are allowed. // validateLocalhostURL parses and validates that a URL targets localhost.
// Pass an empty string to disable the proxy. func validateLocalhostURL(target string) (*url.URL, error) {
func (h *Handler) SetDevTarget(target string) 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() h.devMu.Lock()
defer h.devMu.Unlock() 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.devTarget = nil
h.devProxy = nil h.devProxy = nil
}
if h.notifier != nil { if h.notifier != nil {
h.notifier.Notify() h.notifier.Notify()
} }
return nil return nil
} }
u, err := url.Parse(target) // 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(dt.Target)
if err != nil { if err != nil {
return fmt.Errorf("invalid URL: %w", err) return fmt.Errorf("invalid URL: %w", err)
} }
host := u.Hostname() // Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems
if host != "localhost" && host != "127.0.0.1" && host != "::1" { // where localhost resolves to [::1] but the dev server only listens on IPv4.
return fmt.Errorf("only localhost targets are allowed, got %q", host) 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.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 { if h.notifier != nil {
h.notifier.Notify() h.notifier.Notify()
} }
@ -230,6 +325,29 @@ func (h *Handler) GetDevTarget() string {
return h.devTarget.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. // RegisterRoutes registers Mini App routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/miniapp", h.serveIndex) 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) { func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
target := h.GetDevTarget() writeJSON(w, h.devStatus())
writeJSON(w, map[string]any{
"active": target != "",
"target": target,
})
case http.MethodPost: case http.MethodPost:
body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil { if err != nil {
@ -357,21 +471,33 @@ func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
Target string `json:"target"` Action string `json:"action"`
ID string `json:"id"`
} }
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return return
} }
if err := h.SetDevTarget(req.Target); err != nil { 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()}) writeJSON(w, map[string]any{"error": err.Error()})
return return
} }
target := h.GetDevTarget() case "deactivate":
writeJSON(w, map[string]any{ if err := h.DeactivateDevTarget(); err != nil {
"active": target != "", writeJSON(w, map[string]any{"error": err.Error()})
"target": target, return
}) }
default:
writeJSON(w, map[string]any{"error": "unknown action"})
return
}
writeJSON(w, h.devStatus())
default: default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) 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 { 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{ return map[string]any{
"active": target != "", "active": active,
"active_id": h.devActiveID,
"target": target, "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; } .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> </style>
</head> </head>
<body> <body>
@ -688,24 +740,15 @@
</div> </div>
<div id="dev" class="panel"> <div id="dev" class="panel">
<div id="dev-status" class="card glass"> <div id="dev-header" class="dev-header">
<div class="card-title">Dev Preview</div> <span id="dev-dot" class="dev-target-dot"></span>
<div class="card-value" id="dev-status-label">Inactive</div> <span class="dev-header-title">Dev Preview</span>
<div id="dev-target-display" style="color:var(--hint);margin-top:4px;font-size:13px"></div> <span id="dev-header-target" class="dev-header-target"></span>
</div> </div>
<div class="card glass"> <div id="dev-targets-list"></div>
<div class="card-title">Target URL</div> <div id="dev-iframe-wrap" class="hidden" style="margin-top:8px">
<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 class="card glass" style="padding:0;overflow:hidden"> <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> </div>
</div> </div>
@ -1264,64 +1307,75 @@ function renderGitDetail(repo) {
} }
// ── Dev tab ── // ── Dev tab ──
var devActiveId = '';
function renderDevFromData(data) { function renderDevFromData(data) {
var label = document.getElementById('dev-status-label'); var dot = document.getElementById('dev-dot');
var display = document.getElementById('dev-target-display'); var headerTarget = document.getElementById('dev-header-target');
var targetsList = document.getElementById('dev-targets-list');
var iframeWrap = document.getElementById('dev-iframe-wrap'); var iframeWrap = document.getElementById('dev-iframe-wrap');
var iframe = document.getElementById('dev-iframe'); var iframe = document.getElementById('dev-iframe');
var targets = data.targets || [];
devActiveId = data.active_id || '';
if (data.active) { if (data.active) {
label.textContent = 'Active'; dot.classList.add('on');
label.style.color = 'var(--done)'; headerTarget.textContent = data.target ? data.target.replace(/^https?:\/\//, '') : '';
display.textContent = data.target;
iframeWrap.classList.remove('hidden'); iframeWrap.classList.remove('hidden');
var iframeSrc = location.origin + '/miniapp/dev/'; var iframeSrc = location.origin + '/miniapp/dev/';
if (iframe.src !== iframeSrc) iframe.src = iframeSrc; if (iframe.src !== iframeSrc) iframe.src = iframeSrc;
} else { } else {
label.textContent = 'Inactive'; dot.classList.remove('on');
label.style.color = ''; headerTarget.textContent = '';
display.textContent = '';
iframeWrap.classList.add('hidden'); iframeWrap.classList.add('hidden');
iframe.src = ''; 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() { function loadDev() {
apiFetch('/miniapp/api/dev').then(renderDevFromData).catch(function() {}); 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 ── // ── SSE real-time updates ──
var eventSource = null; var eventSource = null;

View file

@ -3,24 +3,26 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"strings"
"github.com/sipeed/picoclaw/pkg/miniapp" "github.com/sipeed/picoclaw/pkg/miniapp"
) )
// DevPreviewTool allows the agent to control the Mini App dev reverse proxy. // DevPreviewTool allows the agent to control the Mini App dev reverse proxy.
type DevPreviewTool struct { type DevPreviewTool struct {
setter miniapp.DevTargetSetter manager miniapp.DevTargetManager
} }
// NewDevPreviewTool creates a new DevPreviewTool. // NewDevPreviewTool creates a new DevPreviewTool.
func NewDevPreviewTool(setter miniapp.DevTargetSetter) *DevPreviewTool { func NewDevPreviewTool(manager miniapp.DevTargetManager) *DevPreviewTool {
return &DevPreviewTool{setter: setter} return &DevPreviewTool{manager: manager}
} }
func (t *DevPreviewTool) Name() string { return "dev_preview" } func (t *DevPreviewTool) Name() string { return "dev_preview" }
func (t *DevPreviewTool) Description() string { 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 { func (t *DevPreviewTool) Parameters() map[string]any {
@ -29,13 +31,21 @@ func (t *DevPreviewTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
"enum": []string{"start", "stop", "status"}, "enum": []string{"start", "stop", "unregister", "status"},
"description": "Action to perform: start (set proxy target), stop (disable proxy), status (check current target).", "description": "Action to perform: start (register + activate target), stop (deactivate proxy), unregister (remove a registered target), status (list all targets).",
}, },
"target": map[string]any{ "target": map[string]any{
"type": "string", "type": "string",
"description": "Target URL for the dev server (e.g. http://localhost:3000). Required for 'start' action. Must be a localhost URL.", "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"}, "required": []string{"action"},
} }
@ -53,25 +63,71 @@ func (t *DevPreviewTool) Execute(ctx context.Context, args map[string]any) *Tool
if target == "" { if target == "" {
return ErrorResult("target is required for start action") return ErrorResult("target is required for start action")
} }
if err := t.setter.SetDevTarget(target); err != nil { name, _ := args["name"].(string)
return ErrorResult(fmt.Sprintf("failed to set dev target: %v", err)) 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": 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 ErrorResult(fmt.Sprintf("failed to stop dev preview: %v", err))
} }
return SilentResult("Dev preview stopped.") return SilentResult("Dev preview stopped.")
case "status": case "unregister":
target := t.setter.GetDevTarget() id, _ := args["id"].(string)
if target == "" { if id == "" {
return SilentResult("Dev preview is not active.") 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: default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action)) 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" "fmt"
"strings" "strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/miniapp"
) )
// mockDevTargetSetter implements miniapp.DevTargetSetter for testing. // mockDevTargetManager implements miniapp.DevTargetManager for testing.
type mockDevTargetSetter struct { type mockDevTargetManager struct {
target string targets map[string]*miniapp.DevTarget
err error nextID int
activeID string
active string // active target URL
regErr error
} }
func (m *mockDevTargetSetter) SetDevTarget(target string) error { func newMockManager() *mockDevTargetManager {
if m.err != nil { return &mockDevTargetManager{targets: make(map[string]*miniapp.DevTarget)}
return m.err }
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 return nil
} }
func (m *mockDevTargetSetter) GetDevTarget() string { func (m *mockDevTargetManager) ActivateDevTarget(id string) error {
return m.target 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) { func TestDevPreviewTool_Start(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) 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{ result := tool.Execute(context.Background(), map[string]any{
"action": "start", "action": "start",
@ -37,17 +111,17 @@ func TestDevPreviewTool_Start(t *testing.T) {
if result.IsError { if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM) t.Fatalf("expected success, got error: %s", result.ForLLM)
} }
if setter.target != "http://localhost:3000" { // Auto-generated name should be "localhost:3000"
t.Errorf("expected target http://localhost:3000, got %q", setter.target) for _, dt := range mgr.targets {
if dt.Name != "localhost:3000" {
t.Errorf("expected auto-name 'localhost:3000', got %q", dt.Name)
} }
if !strings.Contains(result.ForLLM, "started") {
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
} }
} }
func TestDevPreviewTool_StartMissingTarget(t *testing.T) { func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"action": "start", "action": "start",
@ -59,8 +133,9 @@ func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
} }
func TestDevPreviewTool_StartError(t *testing.T) { func TestDevPreviewTool_StartError(t *testing.T) {
setter := &mockDevTargetSetter{err: fmt.Errorf("only localhost")} mgr := newMockManager()
tool := NewDevPreviewTool(setter) mgr.regErr = fmt.Errorf("only localhost")
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"action": "start", "action": "start",
@ -68,13 +143,14 @@ func TestDevPreviewTool_StartError(t *testing.T) {
}) })
if !result.IsError { if !result.IsError {
t.Error("expected error for setter failure") t.Error("expected error for registration failure")
} }
} }
func TestDevPreviewTool_Stop(t *testing.T) { func TestDevPreviewTool_Stop(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:3000"} mgr := newMockManager()
tool := NewDevPreviewTool(setter) mgr.active = "http://localhost:3000"
tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"action": "stop", "action": "stop",
@ -83,14 +159,65 @@ func TestDevPreviewTool_Stop(t *testing.T) {
if result.IsError { if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM) t.Fatalf("expected success, got error: %s", result.ForLLM)
} }
if setter.target != "" { if mgr.active != "" {
t.Errorf("expected empty target after stop, got %q", setter.target) 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) { func TestDevPreviewTool_Status(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:8080"} mgr := newMockManager()
tool := NewDevPreviewTool(setter) 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{ result := tool.Execute(context.Background(), map[string]any{
"action": "status", "action": "status",
@ -105,11 +232,17 @@ func TestDevPreviewTool_Status(t *testing.T) {
if !strings.Contains(result.ForLLM, "http://localhost:8080") { if !strings.Contains(result.ForLLM, "http://localhost:8080") {
t.Errorf("expected target URL in result, got %q", result.ForLLM) 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) { func TestDevPreviewTool_StatusInactive(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"action": "status", "action": "status",
@ -124,8 +257,8 @@ func TestDevPreviewTool_StatusInactive(t *testing.T) {
} }
func TestDevPreviewTool_UnknownAction(t *testing.T) { func TestDevPreviewTool_UnknownAction(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"action": "restart", "action": "restart",
@ -137,8 +270,8 @@ func TestDevPreviewTool_UnknownAction(t *testing.T) {
} }
func TestDevPreviewTool_MissingAction(t *testing.T) { func TestDevPreviewTool_MissingAction(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) tool := NewDevPreviewTool(mgr)
result := tool.Execute(context.Background(), map[string]any{}) result := tool.Execute(context.Background(), map[string]any{})
@ -148,8 +281,8 @@ func TestDevPreviewTool_MissingAction(t *testing.T) {
} }
func TestDevPreviewTool_NameAndSchema(t *testing.T) { func TestDevPreviewTool_NameAndSchema(t *testing.T) {
setter := &mockDevTargetSetter{} mgr := newMockManager()
tool := NewDevPreviewTool(setter) tool := NewDevPreviewTool(mgr)
if tool.Name() != "dev_preview" { if tool.Name() != "dev_preview" {
t.Errorf("expected name dev_preview, got %q", tool.Name()) 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") 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)
}
}