diff --git a/CHANGELOG.md b/CHANGELOG.md index b917b08..8807967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## v0.7.9 + +### Tests pilotés par l'IA — robustesse + captures d'écran + +Quatre problèmes signalés par l'utilisateur : + +1. **Connexion perdue à chaque reload / navigation**. Le token était à usage unique (5 min TTL) et tombait dès la première reconnexion → l'IA perdait totalement la session. +2. **Page nouvellement ouverte invisible à l'IA**. Conséquence du même bug ci-dessus + JS context détruit à la navigation. +3. **Pas de captures d'écran**. L'IA ne pouvait pas prouver visuellement l'état d'une page. +4. **L'IA se perd en boucle d'outils** : ~150 appels pour l'équivalent de 5 actions humaines, parce qu'elle re-listait les éléments cliquables après chaque clic. + +**Fixes** : + +- **Token réutilisable avec TTL coulissant** (`ConsumeToken` ne supprime plus le token, refresh la TTL à 60 min sur chaque utilisation). L'utilisateur peut re-coller le même snippet de l'onglet Tests sans avoir à régénérer un token, et la session reprend transparente. +- **Auto-reconnect dans le snippet** avec backoff exponentiel (500ms × tentative, max 5 tentatives = ~2,5s). Couvre les déconnexions transitoires (réseau, hibernation, redémarrage du serveur Muyue). Pour une vraie navigation full-page (URL change, JS context détruit), aucun JS ne peut survivre — l'utilisateur doit recoller le snippet, mais c'est immédiat car le token reste valide. +- **Nouvelle action `screenshot`** : le snippet capture la viewport (ou un sélecteur via `selector`) en SVG `foreignObject` + canvas, retourne un data URL base64. Le serveur décode et sauve dans `~/.muyue/screenshots/.png` (nom personnalisable via `filename`, sinon timestamp). Best-effort — CSS externes / images cross-origin / iframes peuvent ne pas apparaître ; les sélecteurs sont sanitisés (pas d'évasion vers d'autres dossiers). +- **Stratégie de test re-écrite dans le system prompt Studio** : règle d'or *"ne PAS ré-appeler `list_clickables` après chaque clic"*. Tableau des actions avec leur coût relatif (`summary` cher mais utile au début, `eval` ciblé > `list_clickables` complet, etc.). Format de rapport final standardisé (✓ / ✗ / ⚠ / 📸). + +Inclut également **v0.7.8** (fix régression v0.7.6 : `unsafe.Pointer(uintptr(hPC))` au lieu de `&hPC` dans `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE)` — corrige les terminaux qui s'ouvraient en fenêtre externe). + ## v0.7.8 ### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe diff --git a/internal/agent/prompts/studio_system.md b/internal/agent/prompts/studio_system.md index 68a4e7f..356cc7a 100644 --- a/internal/agent/prompts/studio_system.md +++ b/internal/agent/prompts/studio_system.md @@ -66,23 +66,52 @@ Muyue gère : | **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `` ci-dessous | -Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet. +Quand l'utilisateur demande de **tester** une UI (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit être connectée via le snippet de l'onglet **Tests** — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller). -Boucle recommandée : +## Règle d'or — économise les appels d'outils -1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes. -2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables. -3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`). -4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé. -5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug. -6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments. -7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission. -8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés. +**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments. + +Stratégie efficace : + +1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche). +2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic. +3. **Re-list seulement si** : + - le `current_url` retourné change ET la nouvelle page est inconnue, + - OU un clic ouvre un dialog / nouveau composant que tu dois inspecter, + - OU `click` retourne `element not found` (DOM a muté). +4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas. +5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels. + +## Actions disponibles + +| Action | Quand l'utiliser | +|---|---| +| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. | +| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. | +| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. | +| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. | +| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. | +| `current_url` | URL+titre. Très bon marché. | +| `wait` | Pause 200-500 ms après une action async (transition / fetch). | +| `console` | N dernières lignes console (default 50). Pour debug post-incident. | +| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). | + +## Rapport final + +Quand tous les tests sont terminés, fournis un rapport **structuré et bref** : + +``` +✓ Boutons OK : +✗ Boutons cassés : diff --git a/internal/api/browsertest.go b/internal/api/browsertest.go index b61ca90..1e9c1df 100644 --- a/internal/api/browsertest.go +++ b/internal/api/browsertest.go @@ -12,10 +12,12 @@ package api import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -24,8 +26,20 @@ import ( "github.com/muyue/muyue/internal/agent" ) +// thin os wrappers (kept here so saveScreenshot stays independent of any +// existing helper file's evolution) +func osUserHomeDir() (string, error) { return os.UserHomeDir() } +func mkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) } +func writeFile(p string, b []byte, m os.FileMode) error { return os.WriteFile(p, b, m) } +func base64StdDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } + const ( - browserTestTokenTTL = 5 * time.Minute + // browserTestTokenTTL is a sliding window: every successful WS connect + // using the token resets it. So the user re-pasting the snippet after a + // page reload / navigation seamlessly resumes (same token, same session + // continuation in the AI's view), as long as no more than this gap of + // inactivity occurs. + browserTestTokenTTL = 60 * time.Minute browserTestCommandTTL = 30 * time.Second browserTestConsoleMax = 200 browserTestSessionsMax = 16 @@ -86,7 +100,11 @@ func (s *BrowserTestStore) IssueToken() string { return tok } -// ConsumeToken validates and removes a token in one step. +// ConsumeToken validates a token. Tokens are no longer single-use: +// the test snippet re-establishes the WS after every page reload / +// navigation, so the same token must work multiple times. We slide the +// expiration on each successful use so a long active test session keeps +// the token alive. func (s *BrowserTestStore) ConsumeToken(tok string) bool { s.tokensMu.Lock() defer s.tokensMu.Unlock() @@ -94,8 +112,12 @@ func (s *BrowserTestStore) ConsumeToken(tok string) bool { if !ok { return false } - delete(s.tokens, tok) - return time.Since(t) <= browserTestTokenTTL + if time.Since(t) > browserTestTokenTTL { + delete(s.tokens, tok) + return false + } + s.tokens[tok] = time.Now() // sliding refresh + return true } // Register inserts a new session, evicting the oldest if at capacity. @@ -377,14 +399,15 @@ func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) { // BrowserTestParams is the schema exposed to the AI for the browser_test tool. type BrowserTestParams struct { - Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"` + Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary, screenshot"` SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"` - Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"` + Selector string `json:"selector,omitempty" description:"CSS selector for click/type/screenshot actions (screenshot defaults to whole viewport when omitted)"` Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"` Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"` Text string `json:"text,omitempty" description:"Text to type (type action only)"` WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"` Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"` + Filename string `json:"filename,omitempty" description:"Screenshot action: optional file name (no path, no extension); defaults to a timestamp"` } // RegisterBrowserTestTool wires the agent tool against a session store. @@ -401,7 +424,7 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error switch action { case "": return agent.TextErrorResponse("action is required"), nil - case "list_clickables", "click", "eval", "current_url", "type": + case "list_clickables", "click", "eval", "current_url", "type", "screenshot": case "console", "summary", "wait": default: return agent.TextErrorResponse("unknown action: " + p.Action), nil @@ -479,6 +502,23 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error return agent.TextErrorResponse(err.Error()), nil } + // Screenshot post-processing: snippet returns a base64 data URL; + // decode and write to ~/.muyue/screenshots/.png so the + // AI can reference an on-disk path rather than streaming megabytes + // of base64 back through its context. + if action == "screenshot" { + saved, perr := saveScreenshot(payload, p.Filename) + if perr != nil { + return agent.TextErrorResponse("screenshot save: " + perr.Error()), nil + } + out, _ := json.MarshalIndent(map[string]interface{}{ + "action": "screenshot", + "saved_to": saved, + "current_url": sess.URL, + }, "", " ") + return agent.TextResponse(string(out)), nil + } + // Console delta: messages logged during this command. post := sess.SnapshotConsole() var delta []ConsoleEntry @@ -504,14 +544,21 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error // Snippet generator ---------------------------------------------------------- func buildBrowserTestSnippet(wsURL string) string { - // Note: this is the JS injected into the user's target page. It opens the - // WS, hooks console, and dispatches commands. Kept terse on purpose. + // Inline JS injected into the user's target page. Responsibilities: + // - open the WS, with auto-reconnect (exponential backoff capped at 5s) + // - hook console.log/info/warn/error/debug + window.onerror + unhandledrejection + // - dispatch RPC commands: list_clickables, click, type, eval, current_url, screenshot + // - re-establish WS on transient close (network blip, server restart, etc.) + // + // Across full page navigation / reload the JS context is destroyed — + // no JS-only mechanism can survive that. The token is reusable (sliding + // 60-min TTL server-side), so the user just re-pastes the same snippet + // from the Tests tab to resume. return `(function(){ if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; } var WS_URL = ` + jsString(wsURL) + `; - var ws = new WebSocket(WS_URL); - var lastList = []; - function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} } + var ws = null, lastList = [], retry = 0; + function send(obj){ try{ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }catch(e){} } function reply(id, data){ send({type:'reply', id:id, data:data}); } function safeText(el){ var t = (el.innerText || el.textContent || '').trim(); @@ -537,6 +584,36 @@ func buildBrowserTestSnippet(wsURL string) string { try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; } catch(e){ return { ok:false, error:String(e) }; } } + // Best-effort viewport screenshot via SVG foreignObject — works on most + // pages, but external CSS / images / iframes won't be inlined. Returns a + // base64 PNG data URL the server will save to disk. + function screenshot(p){ + return new Promise(function(resolve){ + try { + var w = Math.max(document.documentElement.clientWidth, 1024); + var h = Math.max(window.innerHeight, 768); + var node = (p && p.selector) ? document.querySelector(p.selector) : document.documentElement; + if (!node) { resolve({ ok:false, error:'selector not found' }); return; } + var rect = node.getBoundingClientRect(); + if (node === document.documentElement) { rect = { width:w, height:h }; } + var clone = node.cloneNode(true); + var ser = new XMLSerializer().serializeToString(clone); + var svg = '' + + '
' + ser + '
'; + var img = new Image(); + img.onload = function(){ + try { + var c = document.createElement('canvas'); + c.width = Math.round(rect.width); c.height = Math.round(rect.height); + c.getContext('2d').drawImage(img, 0, 0); + resolve({ ok:true, data_url: c.toDataURL('image/png'), width: c.width, height: c.height }); + } catch(e){ resolve({ ok:false, error:'canvas: '+String(e) }); } + }; + img.onerror = function(){ resolve({ ok:false, error:'image load failed (CSP or invalid SVG)' }); }; + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + } catch(e){ resolve({ ok:false, error:String(e) }); } + }); + } function dispatch(msg){ var p = msg.params || {}; switch(msg.action){ @@ -563,6 +640,8 @@ func buildBrowserTestSnippet(wsURL string) string { el.dispatchEvent(new Event('change', {bubbles:true})); return { ok:true }; } + case 'screenshot': + return screenshot(p); } return { ok:false, error:'unknown action' }; } @@ -594,15 +673,29 @@ func buildBrowserTestSnippet(wsURL string) string { setInterval(function(){ if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); } }, 500); - ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); }; - ws.onmessage = function(ev){ - try { var msg = JSON.parse(ev.data); } - catch(e){ return; } - if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; } - if (msg.action) reply(msg.id, dispatch(msg)); - }; - ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; }; - window.__muyueTestRunner = { ws: ws, list: list }; + function connect(){ + ws = new WebSocket(WS_URL); + ws.onopen = function(){ retry = 0; send({type:'hello', url: location.href, title: document.title}); }; + ws.onmessage = function(ev){ + try { var msg = JSON.parse(ev.data); } catch(e){ return; } + if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; } + if (msg.action) { + var out = dispatch(msg); + if (out && typeof out.then === 'function') { out.then(function(r){ reply(msg.id, r); }); } + else { reply(msg.id, out); } + } + }; + ws.onclose = function(){ + // Same-page transient disconnect → reconnect with backoff up to ~5s. + // Full navigation kills the JS context entirely — this never runs in + // that case; the user re-pastes the snippet (same token works). + retry = Math.min(retry + 1, 5); + setTimeout(connect, 500 * retry); + }; + ws.onerror = function(){ /* onclose will fire next */ }; + } + connect(); + window.__muyueTestRunner = { reconnect: connect, list: list }; })();` } @@ -610,3 +703,70 @@ func jsString(s string) string { b, _ := json.Marshal(s) return string(b) } + +// saveScreenshot decodes the base64 PNG returned by the snippet's +// screenshot action and writes it to ~/.muyue/screenshots/.png. +// Returns the absolute path saved, or an error. +func saveScreenshot(replyPayload json.RawMessage, requestedName string) (string, error) { + var reply struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DataURL string `json:"data_url,omitempty"` + } + if err := json.Unmarshal(replyPayload, &reply); err != nil { + return "", fmt.Errorf("invalid reply: %w", err) + } + if !reply.OK { + if reply.Error != "" { + return "", fmt.Errorf("snippet: %s", reply.Error) + } + return "", fmt.Errorf("snippet returned ok=false") + } + const prefix = "data:image/png;base64," + if !strings.HasPrefix(reply.DataURL, prefix) { + return "", fmt.Errorf("unexpected data URL prefix") + } + raw, err := base64StdDecode(reply.DataURL[len(prefix):]) + if err != nil { + return "", fmt.Errorf("base64: %w", err) + } + dir, err := screenshotDir() + if err != nil { + return "", err + } + name := sanitizeFilename(requestedName) + if name == "" { + name = time.Now().Format("20060102-150405") + } + path := dir + "/" + name + ".png" + if err := writeFile(path, raw, 0644); err != nil { + return "", err + } + return path, nil +} + +func screenshotDir() (string, error) { + home, err := osUserHomeDir() + if err != nil { + return "", err + } + dir := home + "/.muyue/screenshots" + if err := mkdirAll(dir, 0755); err != nil { + return "", err + } + return dir, nil +} + +// sanitizeFilename keeps a safe subset (letters / digits / _ / - / .) so +// the user-supplied name cannot escape the screenshots directory. +func sanitizeFilename(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '_', r == '-', r == '.': + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/version/version.go b/internal/version/version.go index 9b4dc74..a54710a 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.7.8" + Version = "0.7.9" Author = "La Légion de Muyue" )