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"
|
||||
"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.
|
||||
|
|
@ -175,6 +188,9 @@ type Handler struct {
|
|||
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.
|
||||
|
|
@ -184,36 +200,115 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no
|
|||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
target := h.GetDevTarget()
|
||||
writeJSON(w, map[string]any{
|
||||
"active": target != "",
|
||||
"target": target,
|
||||
})
|
||||
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
|
||||
}
|
||||
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 != "",
|
||||
"active": active,
|
||||
"active_id": h.devActiveID,
|
||||
"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; }
|
||||
|
||||
/* 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// 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)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "started") {
|
||||
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue