feat: add dev preview proxy, tool, and Mini App tab

Expose local dev servers through the Mini App reverse proxy so agents
can let users preview in-progress web apps directly in Telegram.

- Add /miniapp/dev/ reverse proxy with localhost-only validation
- Add /miniapp/api/dev GET/POST endpoints and SSE "dev" event
- Add dev_preview tool (start/stop/status) for agent control
- Add Dev tab to Mini App with status, target input, and iframe preview
- Add dev-preview skill (SKILL.md) in workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 18:24:22 +09:00
parent 0700fa63c6
commit cd7515920a
6 changed files with 591 additions and 9 deletions

View file

@ -238,6 +238,11 @@ func gatewayCmd() {
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier) handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
agentLoop.OnStateChange = miniappNotifier.Notify agentLoop.OnStateChange = miniappNotifier.Notify
handler.RegisterRoutes(healthServer.Mux()) handler.RegisterRoutes(healthServer.Mux())
// Register dev preview tool for all agents
devPreviewTool := tools.NewDevPreviewTool(handler)
agentLoop.RegisterTool(devPreviewTool)
fmt.Printf("✓ Mini App registered at %s\n", webAppURL) fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
} }
} }

View file

@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
@ -158,12 +159,22 @@ func (n *StateNotifier) Notify() {
} }
} }
// DevTargetSetter allows tools to control the dev proxy target.
type DevTargetSetter interface {
SetDevTarget(target string) error
GetDevTarget() string
}
// Handler serves the Mini App HTML and API endpoints. // Handler serves the Mini App HTML and API endpoints.
type Handler struct { type Handler struct {
provider DataProvider provider DataProvider
sender CommandSender sender CommandSender
botToken string botToken string
notifier *StateNotifier notifier *StateNotifier
devMu sync.RWMutex
devTarget *url.URL
devProxy *httputil.ReverseProxy
} }
// NewHandler creates a new Mini App handler. // NewHandler creates a new Mini App handler.
@ -176,6 +187,49 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no
} }
} }
// 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 {
h.devMu.Lock()
defer h.devMu.Unlock()
if target == "" {
h.devTarget = nil
h.devProxy = nil
if h.notifier != nil {
h.notifier.Notify()
}
return nil
}
u, err := url.Parse(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)
}
h.devTarget = u
h.devProxy = httputil.NewSingleHostReverseProxy(u)
if h.notifier != nil {
h.notifier.Notify()
}
return nil
}
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
func (h *Handler) GetDevTarget() string {
h.devMu.RLock()
defer h.devMu.RUnlock()
if h.devTarget == nil {
return ""
}
return h.devTarget.String()
}
// 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)
@ -185,7 +239,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions)) mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit)) mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
} }
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) { func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
@ -286,6 +342,59 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
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,
})
case http.MethodPost:
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
return
}
var req struct {
Target string `json:"target"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if err := h.SetDevTarget(req.Target); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
target := h.GetDevTarget()
writeJSON(w, map[string]any{
"active": target != "",
"target": target,
})
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
h.devMu.RLock()
proxy := h.devProxy
h.devMu.RUnlock()
if proxy == nil {
http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable)
return
}
// Strip /miniapp/dev prefix so /miniapp/dev/foo → /foo
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/miniapp/dev")
if r.URL.Path == "" {
r.URL.Path = "/"
}
proxy.ServeHTTP(w, r)
}
// extractUserFromInitData parses user.id from the initData query string. // extractUserFromInitData parses user.id from the initData query string.
// initData contains a "user" param with JSON like {"id":123456,...}. // initData contains a "user" param with JSON like {"id":123456,...}.
func extractUserFromInitData(initData string) (userID, chatID string) { func extractUserFromInitData(initData string) (userID, chatID string) {
@ -325,7 +434,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
ch := h.notifier.Subscribe() ch := h.notifier.Subscribe()
defer h.notifier.Unsubscribe(ch) defer h.notifier.Unsubscribe(ch)
var lastPlan, lastSession, lastSkills []byte var lastPlan, lastSession, lastSkills, lastDev []byte
// Send initial state immediately // Send initial state immediately
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
@ -333,6 +442,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
&lastSession) &lastSession)
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
for { for {
select { select {
@ -346,10 +456,19 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
&lastSession) &lastSession)
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
} }
} }
} }
func (h *Handler) devStatus() map[string]any {
target := h.GetDevTarget()
return map[string]any{
"active": target != "",
"target": target,
}
}
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) { func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
data, _ := json.Marshal(v) data, _ := json.Marshal(v)
if !bytes.Equal(data, *last) { if !bytes.Equal(data, *last) {

View file

@ -6,6 +6,7 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -248,7 +249,7 @@ func TestSSE_InitialEvents(t *testing.T) {
events := make(map[string]bool) events := make(map[string]bool)
deadline := time.After(2 * time.Second) deadline := time.After(2 * time.Second)
for len(events) < 3 { for len(events) < 4 {
done := make(chan bool, 1) done := make(chan bool, 1)
go func() { go func() {
done <- scanner.Scan() done <- scanner.Scan()
@ -267,7 +268,7 @@ func TestSSE_InitialEvents(t *testing.T) {
} }
} }
for _, name := range []string{"plan", "session", "skills"} { for _, name := range []string{"plan", "session", "skills", "dev"} {
if !events[name] { if !events[name] {
t.Errorf("missing initial event %q", name) t.Errorf("missing initial event %q", name)
} }
@ -374,8 +375,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial 3 events // Drain initial 4 events (plan, session, skills, dev)
drainEvents(t, scanner, 3, 2*time.Second) drainEvents(t, scanner, 4, 2*time.Second)
// Mutate state and notify — diff dedup should detect the change and send a new event // Mutate state and notify — diff dedup should detect the change and send a new event
provider.mutated.Store(true) provider.mutated.Store(true)
@ -403,8 +404,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial events // Drain initial events (plan, session, skills, dev)
drainEvents(t, scanner, 3, 2*time.Second) drainEvents(t, scanner, 4, 2*time.Second)
// Notify with unchanged data — should produce zero new event lines // Notify with unchanged data — should produce zero new event lines
notifier.Notify() notifier.Notify()
@ -458,6 +459,135 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
return GitInfo{Name: name} return GitInfo{Name: name}
} }
// ── Dev proxy tests ──
func TestDevProxy_SetAndGet(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
// Initially empty
if got := h.GetDevTarget(); got != "" {
t.Errorf("expected empty target, got %q", got)
}
// Set a valid target
if err := h.SetDevTarget("http://localhost:3000"); err != nil {
t.Fatalf("SetDevTarget failed: %v", err)
}
if got := h.GetDevTarget(); got != "http://localhost:3000" {
t.Errorf("expected http://localhost:3000, got %q", got)
}
// Clear target
if err := h.SetDevTarget(""); err != nil {
t.Fatalf("SetDevTarget(\"\") failed: %v", err)
}
if got := h.GetDevTarget(); got != "" {
t.Errorf("expected empty target after clear, got %q", got)
}
}
func TestDevProxy_LocalhostOnly(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
// External host should be rejected
if err := h.SetDevTarget("http://example.com:3000"); err == nil {
t.Error("expected error for external host, got nil")
}
// 127.0.0.1 should be allowed
if err := h.SetDevTarget("http://127.0.0.1:8080"); err != nil {
t.Errorf("expected 127.0.0.1 to be allowed, got %v", err)
}
// ::1 should be allowed
if err := h.SetDevTarget("http://[::1]:9000"); err != nil {
t.Errorf("expected [::1] to be allowed, got %v", err)
}
}
func TestDevProxy_ReverseProxy(t *testing.T) {
// Create a backend server
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "path=%s", r.URL.Path)
}))
defer backend.Close()
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Set target to the test backend
if err := h.SetDevTarget(backend.URL); err != nil {
t.Fatalf("SetDevTarget failed: %v", err)
}
// Request through the proxy
req := httptest.NewRequest("GET", "/miniapp/dev/hello", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if got := w.Body.String(); got != "path=/hello" {
t.Errorf("expected path=/hello, got %q", got)
}
}
func TestDevProxy_503WhenNotConfigured(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestDevProxy_APIEndpoint(t *testing.T) {
notifier := NewStateNotifier()
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
initData := testInitData()
// GET — initially inactive
resp, err := http.Get(ts.URL + "/miniapp/api/dev?initData=" + url.QueryEscape(initData))
if err != nil {
t.Fatalf("GET failed: %v", err)
}
defer resp.Body.Close()
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
if result["active"] != false {
t.Errorf("expected active=false, got %v", result["active"])
}
// POST — set target
body := strings.NewReader(`{"target":"http://localhost:4000"}`)
resp2, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(initData), "application/json", body)
if err != nil {
t.Fatalf("POST failed: %v", err)
}
defer resp2.Body.Close()
var result2 map[string]any
json.NewDecoder(resp2.Body).Decode(&result2)
if result2["active"] != true {
t.Errorf("expected active=true after set, got %v", result2["active"])
}
if result2["target"] != "http://localhost:4000" {
t.Errorf("expected target=http://localhost:4000, got %v", result2["target"])
}
}
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out. // drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool { func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
t.Helper() t.Helper()

View file

@ -84,7 +84,7 @@
top: 2px; top: 2px;
bottom: 2px; bottom: 2px;
left: 2px; left: 2px;
width: calc(20% - 2px); width: calc(100%/6 - 2px);
border-radius: 8px; border-radius: 8px;
background: var(--tab-pill-bg); background: var(--tab-pill-bg);
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08); box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
@ -645,6 +645,7 @@
<button class="tab" data-panel="skills">Skills</button> <button class="tab" data-panel="skills">Skills</button>
<button class="tab" data-panel="session">Session</button> <button class="tab" data-panel="session">Session</button>
<button class="tab" data-panel="config">Config</button> <button class="tab" data-panel="config">Config</button>
<button class="tab" data-panel="dev">Dev</button>
</div> </div>
</div> </div>
@ -686,6 +687,29 @@
<div id="git-content" class="hidden"></div> <div id="git-content" class="hidden"></div>
</div> </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>
<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 class="card glass" style="padding:0;overflow:hidden">
<iframe id="dev-iframe" src="" style="width:100%;height:50vh;border:none;border-radius:16px"></iframe>
</div>
</div>
</div>
<div class="send-bar hidden" id="send-bar"> <div class="send-bar hidden" id="send-bar">
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill..."> <input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button> <button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
@ -698,7 +722,7 @@ tg.ready();
const API_BASE = location.origin; const API_BASE = location.origin;
let initData = tg.initData || ''; let initData = tg.initData || '';
let selectedSkill = null; let selectedSkill = null;
var lastSSE = { plan: 0, skills: 0, session: 0 }; var lastSSE = { plan: 0, skills: 0, session: 0, dev: 0 };
// Tab switching — re-fetch data unless SSE delivered recently // Tab switching — re-fetch data unless SSE delivered recently
const tabs = document.querySelectorAll('.tab'); const tabs = document.querySelectorAll('.tab');
@ -725,6 +749,7 @@ tabs.forEach((tab, index) => {
if (p === 'skills' && !fresh) loadSkills(); if (p === 'skills' && !fresh) loadSkills();
if (p === 'session' && !fresh) loadSession(); if (p === 'session' && !fresh) loadSession();
if (p === 'git') loadGit(); if (p === 'git') loadGit();
if (p === 'dev' && !fresh) loadDev();
}); });
}); });
@ -1238,6 +1263,65 @@ function renderGitDetail(repo) {
el.innerHTML = html; el.innerHTML = html;
} }
// ── Dev tab ──
function renderDevFromData(data) {
var label = document.getElementById('dev-status-label');
var display = document.getElementById('dev-target-display');
var iframeWrap = document.getElementById('dev-iframe-wrap');
var iframe = document.getElementById('dev-iframe');
if (data.active) {
label.textContent = 'Active';
label.style.color = 'var(--done)';
display.textContent = data.target;
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 = '';
iframeWrap.classList.add('hidden');
iframe.src = '';
}
}
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 ── // ── SSE real-time updates ──
var eventSource = null; var eventSource = null;
@ -1259,6 +1343,9 @@ function connectSSE() {
eventSource.addEventListener('skills', function(e) { eventSource.addEventListener('skills', function(e) {
try { lastSSE.skills = Date.now(); renderSkillsFromData(JSON.parse(e.data)); } catch(err) {} try { lastSSE.skills = Date.now(); renderSkillsFromData(JSON.parse(e.data)); } catch(err) {}
}); });
eventSource.addEventListener('dev', function(e) {
try { lastSSE.dev = Date.now(); renderDevFromData(JSON.parse(e.data)); } catch(err) {}
});
eventSource.onerror = function() { eventSource.onerror = function() {
// Browser will auto-reconnect EventSource // Browser will auto-reconnect EventSource
}; };

77
pkg/tools/dev_preview.go Normal file
View file

@ -0,0 +1,77 @@
package tools
import (
"context"
"fmt"
"github.com/sipeed/picoclaw/pkg/miniapp"
)
// DevPreviewTool allows the agent to control the Mini App dev reverse proxy.
type DevPreviewTool struct {
setter miniapp.DevTargetSetter
}
// NewDevPreviewTool creates a new DevPreviewTool.
func NewDevPreviewTool(setter miniapp.DevTargetSetter) *DevPreviewTool {
return &DevPreviewTool{setter: setter}
}
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."
}
func (t *DevPreviewTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"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).",
},
"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.",
},
},
"required": []string{"action"},
}
}
func (t *DevPreviewTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required")
}
switch action {
case "start":
target, _ := args["target"].(string)
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))
}
return SilentResult(fmt.Sprintf("Dev preview started. Target: %s\nUsers can view it in the Mini App Dev tab.", target))
case "stop":
if err := t.setter.SetDevTarget(""); 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.")
}
return SilentResult(fmt.Sprintf("Dev preview is active. Target: %s", target))
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}

View file

@ -0,0 +1,164 @@
package tools
import (
"context"
"fmt"
"strings"
"testing"
)
// mockDevTargetSetter implements miniapp.DevTargetSetter for testing.
type mockDevTargetSetter struct {
target string
err error
}
func (m *mockDevTargetSetter) SetDevTarget(target string) error {
if m.err != nil {
return m.err
}
m.target = target
return nil
}
func (m *mockDevTargetSetter) GetDevTarget() string {
return m.target
}
func TestDevPreviewTool_Start(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://localhost:3000",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if setter.target != "http://localhost:3000" {
t.Errorf("expected target http://localhost:3000, got %q", setter.target)
}
if !strings.Contains(result.ForLLM, "started") {
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
}
}
func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
})
if !result.IsError {
t.Error("expected error for missing target")
}
}
func TestDevPreviewTool_StartError(t *testing.T) {
setter := &mockDevTargetSetter{err: fmt.Errorf("only localhost")}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{
"action": "start",
"target": "http://example.com:3000",
})
if !result.IsError {
t.Error("expected error for setter failure")
}
}
func TestDevPreviewTool_Stop(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:3000"}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{
"action": "stop",
})
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)
}
}
func TestDevPreviewTool_Status(t *testing.T) {
setter := &mockDevTargetSetter{target: "http://localhost:8080"}
tool := NewDevPreviewTool(setter)
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:8080") {
t.Errorf("expected target URL in result, got %q", result.ForLLM)
}
}
func TestDevPreviewTool_StatusInactive(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
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, "not active") {
t.Errorf("expected 'not active' in result, got %q", result.ForLLM)
}
}
func TestDevPreviewTool_UnknownAction(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{
"action": "restart",
})
if !result.IsError {
t.Error("expected error for unknown action")
}
}
func TestDevPreviewTool_MissingAction(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Error("expected error for missing action")
}
}
func TestDevPreviewTool_NameAndSchema(t *testing.T) {
setter := &mockDevTargetSetter{}
tool := NewDevPreviewTool(setter)
if tool.Name() != "dev_preview" {
t.Errorf("expected name dev_preview, got %q", tool.Name())
}
if tool.Description() == "" {
t.Error("expected non-empty description")
}
params := tool.Parameters()
if params == nil {
t.Fatal("expected non-nil parameters")
}
}