feat(browser-test): persistent token + auto-reconnect + screenshot + smarter strategy (v0.7.9)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
Four user-reported issues with the AI-driven browser test feature:
1. WS dies on every page reload / navigation (token was single-use,
5 min TTL → AI loses session permanently).
2. AI can't see new pages opened by its actions (same root cause).
3. No screenshot capability — AI cannot capture page state visually.
4. AI burns ~150 tool calls for what's 5 human actions, mostly
list_clickables loops.
Fixes:
- Token sliding TTL (60 min): ConsumeToken no longer deletes the
token; it refreshes its expiration on each successful WS connect.
Same token survives reload / re-paste / navigation as long as
there's no 60-min idle gap.
- Snippet auto-reconnect: WS onclose schedules reconnect with
500ms × attempt backoff (max ~2.5s). Handles transient drops,
server restarts, and WS hiccups without user intervention. Full
navigation kills the JS context and is unrecoverable from JS — but
the user just re-pastes the snippet, same token works.
- New 'screenshot' action: snippet captures via SVG foreignObject +
canvas → base64 PNG → sent back over the existing WS reply
channel. Server decodes and writes to ~/.muyue/screenshots/
<filename>.png (sanitized name, timestamp default). Filename
characters limited to a safe charset to prevent path escape.
Best-effort: external CSS / cross-origin images / iframes won't
inline.
- Studio system prompt rewritten <browser_test_strategy>:
- Explicit rule: don't list_clickables after every click
- Action cost table (cheap vs expensive)
- When to re-list (URL change, dialog, click-not-found only)
- Standard final report format ✓ / ✗ / ⚠ / 📸
Also bundles v0.7.8 (cherry-picked): unsafe.Pointer(uintptr(hPC))
instead of unsafe.Pointer(&hPC) in UpdateProcThreadAttribute, so
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is correctly applied and the
spawned shell attaches to the embedded xterm.js instead of opening
a separate external console window (regression fix from v0.7.6).
- internal/api/browsertest.go: token sliding, screenshot save,
param schema, snippet rewrite, helpers
- internal/agent/prompts/studio_system.md: strategy rewrite
- internal/version/version.go: 0.7.7 → 0.7.9
- CHANGELOG.md: v0.7.9 entry covering all fixes
This commit is contained in:
20
CHANGELOG.md
20
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/).
|
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/<filename>.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
|
## v0.7.8
|
||||||
|
|
||||||
### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe
|
### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe
|
||||||
|
|||||||
@@ -66,23 +66,52 @@ Muyue gère :
|
|||||||
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||||
|
|
||||||
<browser_test_strategy>
|
<browser_test_strategy>
|
||||||
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.
|
**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.
|
||||||
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`).
|
Stratégie efficace :
|
||||||
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.
|
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||||
6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
|
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||||
7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
|
3. **Re-list seulement si** :
|
||||||
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
|
- 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/<filename>.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 : <liste des labels>
|
||||||
|
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
|
||||||
|
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
|
||||||
|
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
|
||||||
|
```
|
||||||
|
|
||||||
Astuces :
|
Astuces :
|
||||||
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||||
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
|
|
||||||
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||||
|
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
|
||||||
</browser_test_strategy>
|
</browser_test_strategy>
|
||||||
|
|
||||||
<tool_strategy>
|
<tool_strategy>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,8 +26,20 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/agent"
|
"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 (
|
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
|
browserTestCommandTTL = 30 * time.Second
|
||||||
browserTestConsoleMax = 200
|
browserTestConsoleMax = 200
|
||||||
browserTestSessionsMax = 16
|
browserTestSessionsMax = 16
|
||||||
@@ -86,7 +100,11 @@ func (s *BrowserTestStore) IssueToken() string {
|
|||||||
return tok
|
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 {
|
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||||
s.tokensMu.Lock()
|
s.tokensMu.Lock()
|
||||||
defer s.tokensMu.Unlock()
|
defer s.tokensMu.Unlock()
|
||||||
@@ -94,8 +112,12 @@ func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
delete(s.tokens, tok)
|
if time.Since(t) > browserTestTokenTTL {
|
||||||
return 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.
|
// 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.
|
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
||||||
type BrowserTestParams struct {
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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.
|
// RegisterBrowserTestTool wires the agent tool against a session store.
|
||||||
@@ -401,7 +424,7 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
switch action {
|
switch action {
|
||||||
case "":
|
case "":
|
||||||
return agent.TextErrorResponse("action is required"), nil
|
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":
|
case "console", "summary", "wait":
|
||||||
default:
|
default:
|
||||||
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
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
|
return agent.TextErrorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot post-processing: snippet returns a base64 data URL;
|
||||||
|
// decode and write to ~/.muyue/screenshots/<filename>.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.
|
// Console delta: messages logged during this command.
|
||||||
post := sess.SnapshotConsole()
|
post := sess.SnapshotConsole()
|
||||||
var delta []ConsoleEntry
|
var delta []ConsoleEntry
|
||||||
@@ -504,14 +544,21 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
// Snippet generator ----------------------------------------------------------
|
// Snippet generator ----------------------------------------------------------
|
||||||
|
|
||||||
func buildBrowserTestSnippet(wsURL string) string {
|
func buildBrowserTestSnippet(wsURL string) string {
|
||||||
// Note: this is the JS injected into the user's target page. It opens the
|
// Inline JS injected into the user's target page. Responsibilities:
|
||||||
// WS, hooks console, and dispatches commands. Kept terse on purpose.
|
// - 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(){
|
return `(function(){
|
||||||
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||||
var WS_URL = ` + jsString(wsURL) + `;
|
var WS_URL = ` + jsString(wsURL) + `;
|
||||||
var ws = new WebSocket(WS_URL);
|
var ws = null, lastList = [], retry = 0;
|
||||||
var lastList = [];
|
function send(obj){ try{ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }catch(e){} }
|
||||||
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
|
|
||||||
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
||||||
function safeText(el){
|
function safeText(el){
|
||||||
var t = (el.innerText || el.textContent || '').trim();
|
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 }; }
|
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
||||||
catch(e){ return { ok:false, error:String(e) }; }
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="'+Math.round(rect.width)+'" height="'+Math.round(rect.height)+'">' +
|
||||||
|
'<foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:white">' + ser + '</div></foreignObject></svg>';
|
||||||
|
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){
|
function dispatch(msg){
|
||||||
var p = msg.params || {};
|
var p = msg.params || {};
|
||||||
switch(msg.action){
|
switch(msg.action){
|
||||||
@@ -563,6 +640,8 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
el.dispatchEvent(new Event('change', {bubbles:true}));
|
el.dispatchEvent(new Event('change', {bubbles:true}));
|
||||||
return { ok:true };
|
return { ok:true };
|
||||||
}
|
}
|
||||||
|
case 'screenshot':
|
||||||
|
return screenshot(p);
|
||||||
}
|
}
|
||||||
return { ok:false, error:'unknown action' };
|
return { ok:false, error:'unknown action' };
|
||||||
}
|
}
|
||||||
@@ -594,15 +673,29 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
setInterval(function(){
|
setInterval(function(){
|
||||||
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
||||||
}, 500);
|
}, 500);
|
||||||
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
|
function connect(){
|
||||||
ws.onmessage = function(ev){
|
ws = new WebSocket(WS_URL);
|
||||||
try { var msg = JSON.parse(ev.data); }
|
ws.onopen = function(){ retry = 0; send({type:'hello', url: location.href, title: document.title}); };
|
||||||
catch(e){ return; }
|
ws.onmessage = function(ev){
|
||||||
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
try { var msg = JSON.parse(ev.data); } catch(e){ return; }
|
||||||
if (msg.action) reply(msg.id, dispatch(msg));
|
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
||||||
};
|
if (msg.action) {
|
||||||
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
|
var out = dispatch(msg);
|
||||||
window.__muyueTestRunner = { ws: ws, list: list };
|
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)
|
b, _ := json.Marshal(s)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveScreenshot decodes the base64 PNG returned by the snippet's
|
||||||
|
// screenshot action and writes it to ~/.muyue/screenshots/<name>.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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.7.8"
|
Version = "0.7.9"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user