Merge branch 'worktree-dev-preview'
This commit is contained in:
commit
07c49583e8
5 changed files with 2033 additions and 167 deletions
|
|
@ -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, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, "\"", """)
|
||||||
|
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
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue