diff --git a/README.md b/README.md index ced6484..89624d1 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | -| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | +| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md new file mode 100644 index 0000000..c753ee4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md @@ -0,0 +1,138 @@ +# Lot 44 — Navigateur piloté dans la façade + +## Contexte + +Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le +protocole DevTools (CDP). L'agent l'utilise ainsi : + +- il ouvre une page et lit les éléments interactifs numérotés ; +- il clique, tape, défile par numéro ; +- avec la vision, il voit une capture quadrillée et clique en + coordonnées. + +La façade tient la conversation par défaut, mais la session Chrome, les +outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient +au Go (`/internal/v1/tool/exec`). + +## Le lot + +`src/browser/` porte quatre fichiers Go : + +- `computer_cdp.go` : client CDP, lancement, scripts ; +- `computer_use.go` : les neuf outils ; +- `browser_grid.go` : la grille de la capture ; +- `cu_autoshot.go` : les aperçus. + +Ce qui est porté : + +- **le lancement** : + - binaire imposé, celui de Playwright ou du système ; + - profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ; + - mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ; + - tué avec la façade (`PR_SET_PDEATHSIG`) ; + - port lu dans `DevToolsActivePort`, cible « page » par `/json` ; + - viewport fixe 1280×800 ; + - script qui garde les nouveaux onglets dans l'onglet courant ; +- **la session** : + - une seule, lancée à la demande ; + - vérifiée par un ping avant chaque outil et relancée si elle ne répond + plus ; + - commandes multiplexées par numéro, 30 s au plus ; + - requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus) + après un chargement ; +- **les scripts** injectés, copiés du Go à l'octet près (le journal + compare leurs empreintes) : + - éléments actionnables et visibles ; + - shadow DOM ; + - nom d'un champ par son libellé, sans révéler un mot de passe ; + - recherche non destructive ; + - liste déroulante native réglée par son option ; +- **les outils**, mêmes textes : + - snapshot dédoublonné ; + - indices de défilement ; + - erreurs de navigation (`errorText`, page `chrome-error://`, + `ERR_ABORTED` bénin) ; + - `back` et `forward` ; + - touches nommées ; + - plafond de 8000 caractères ; +- **la capture** (`browser_screenshot`, vision active) : + - PNG propre rangé dans le dossier de travail + (`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ; + - image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ; + à 1280×800 elle est sous la taille maximale et part telle quelle ; + - relayée au modèle comme `see_image` ; +- **les aperçus** : + - après une action qui change la page, jamais après une erreur ; + - JPEG en mémoire, anneau de huit ; + - servis par `/api/chat/cushot`, que le Go remet désormais à la façade. + +`ChatShared.browser` porte le navigateur du processus ; +`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu. + +Un seul navigateur : quand la façade tient la conversation, le Go lui passe +ses propres appels (`facade_browser.go`) : + +- outils des sous-agents et des tâches IA ; +- aperçus ; +- fermeture (interrupteur coupé, ligne de commande). + +Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations : +`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go +prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux. + +## Écarts connus + +- Encodeurs PNG et JPEG : mêmes pixels, octets différents. +- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe + rend le cas impossible). + +## Parité + +`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42 +étapes (`tests/parity_browser/browser.json`) contre un faux Chrome : + +- un script annonce le port d'un faux serveur CDP ; +- le faux CDP répond selon le scénario de chaque étape et note chaque + commande. + +Chaque étape porte : + +- le résultat ; +- les commandes CDP (méthode, paramètres, scripts résumés par leur + empreinte) ; +- pour une capture, ses dimensions et dix pixels de la grille ; +- les captures rangées. + +Les arguments de lancement sont relevés aussi. Le journal couvre : + +- ouverture, snapshot inchangé, réouverture complète ; +- snapshots vide, `null`, sans valeur, illisible, exception JS ; +- défilements ; +- clics par numéro, introuvable, refusé, en coordonnées ; +- saisies libre, dans un champ, liste déroulante, option absente, champ + introuvable ; +- touches ; +- recherches ; +- navigations en échec, page d'erreur, interruption bénigne, `back`, + `forward`, commande refusée ; +- captures sans vision, quadrillée, refusée ; +- sortie plafonnée. + +`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG +de l'anneau) et la fermeture (profil retiré). + +`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un +vrai Chromium sur une page locale : ouverture, recherche, saisie, liste +déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation +en échec. + +D'autres tests complètent : + +- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre + une page sans passer par le Go, avec aperçu ; l'API du Go et + `/api/chat/cushot` par une façade complète ; +- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et + fermeture à la façade, et se replie sur son navigateur si elle est en + panne ; +- tests unitaires : outils, nombres au format du Go, arguments, grille, + profils, lancement impossible. diff --git a/internal/loki/computer_cdp.go b/internal/loki/computer_cdp.go index 462aafe..1ac7e23 100644 --- a/internal/loki/computer_cdp.go +++ b/internal/loki/computer_cdp.go @@ -457,6 +457,7 @@ func sweepStaleCUDirs() { } func cdpShutdown() { + facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation cdpMu.Lock() defer cdpMu.Unlock() if cdpCur != nil { diff --git a/internal/loki/computer_use.go b/internal/loki/computer_use.go index e6c7fb1..b432d0b 100644 --- a/internal/loki/computer_use.go +++ b/internal/loki/computer_use.go @@ -169,6 +169,9 @@ func cuPromptLine() string { // ─── exécution (appelée par le dispatch de llm_client.go) ──────────────────── func toolCUOpen(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_open", args); ok { + return r + } url, _ := args["url"].(string) if strings.TrimSpace(url) == "" { return "[erreur] url manquante" @@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string { } func toolCUSnapshot() string { + if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok { + return r + } s, err := cdpGet() if err != nil { return "[erreur] " + err.Error() @@ -200,6 +206,9 @@ func toolCUSnapshot() string { } func toolCUFind(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_find", args); ok { + return r + } text, _ := args["text"].(string) if strings.TrimSpace(text) == "" { return "[erreur] texte de recherche manquant" @@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string { } func toolCUClick(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_click", args); ok { + return r + } ref, ok := intArg(args, "ref") if !ok { return "[erreur] ref manquant (numéro de l'élément)" @@ -232,6 +244,9 @@ func toolCUClick(args map[string]any) string { } func toolCUType(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_type", args); ok { + return r + } text, _ := args["text"].(string) s, err := cdpGet() if err != nil { @@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string { } func toolCUKey(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_key", args); ok { + return r + } key, _ := args["key"].(string) s, err := cdpGet() if err != nil { @@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string { } func toolCUClickXY(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_click_xy", args); ok { + return r + } x, okx := floatArg(args, "x") y, oky := floatArg(args, "y") if !okx || !oky { @@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string { } func toolCUScroll(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_scroll", args); ok { + return r + } dir, _ := args["direction"].(string) s, err := cdpGet() if err != nil { @@ -299,6 +323,12 @@ func toolCUScroll(args map[string]any) string { // PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour // rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown. func toolCUScreenshot() (string, map[string]any) { + if r, img, ok := facadeBrowserTool("browser_screenshot", map[string]any{}); ok { + if len(img) == 0 { + img = nil + } + return r, img + } if !visionEnabled() { return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil } diff --git a/internal/loki/cu_autoshot.go b/internal/loki/cu_autoshot.go index 3e8a79a..de97fed 100644 --- a/internal/loki/cu_autoshot.go +++ b/internal/loki/cu_autoshot.go @@ -46,6 +46,9 @@ func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n" // renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais // bloquant pour l'outil). func cuAutoShot() string { + if id, ok := facadeBrowserShot(); ok { + return id + } s, err := cdpGet() if err != nil { cuLog("aperçu : navigateur indisponible : %v", err) diff --git a/internal/loki/facade_browser.go b/internal/loki/facade_browser.go new file mode 100644 index 0000000..857f189 --- /dev/null +++ b/internal/loki/facade_browser.go @@ -0,0 +1,86 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// facade_browser.go — le navigateur piloté tenu par la façade (lot 44 de la +// migration Rust). Quand elle tient la conversation, la session Chrome est +// la sienne : ses tours exécutent les outils browser_* chez elle, et ceux du +// Go (sous-agents, tâches IA) passent par /internal/facade/v1/browser — un +// seul navigateur. L'aperçu après une action et la fermeture aussi. +// Façade injoignable : le navigateur du Go prend le relais. + +// facadeBrowserClient : une action du navigateur peut attendre le +// lancement de Chrome, puis le chargement et le calme réseau de la page. +var facadeBrowserClient = &http.Client{Timeout: 3 * time.Minute} + +// facadeBrowserCall appelle l'API du navigateur de la façade. +func facadeBrowserCall(body map[string]any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/browser", bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeBrowserClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("façade : %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// facadeBrowserTool exécute un outil browser_* à la façade ; ok faux : elle +// ne tient pas la conversation, ou ne répond pas (le Go le fait lui-même). +func facadeBrowserTool(name string, args map[string]any) (result string, image map[string]any, ok bool) { + if !facadeChat() { + return "", nil, false + } + var out struct { + Result string `json:"result"` + Image map[string]any `json:"image"` + } + if err := facadeBrowserCall(map[string]any{"op": "tool", "name": name, "args": args}, &out); err != nil { + fmt.Fprintf(os.Stderr, "[cu] navigateur de la façade injoignable, repli local : %v\n", err) + return "", nil, false + } + return out.Result, out.Image, true +} + +// facadeBrowserShot : l'aperçu pris par la façade. +func facadeBrowserShot() (string, bool) { + if !facadeChat() { + return "", false + } + var out struct { + ID string `json:"id"` + } + if err := facadeBrowserCall(map[string]any{"op": "shot"}, &out); err != nil { + return "", false + } + return out.ID, true +} + +// facadeBrowserShutdown ferme le navigateur de la façade. +func facadeBrowserShutdown() { + if !facadeChat() { + return + } + var out struct{} + if err := facadeBrowserCall(map[string]any{"op": "shutdown"}, &out); err != nil { + fmt.Fprintf(os.Stderr, "[cu] fermeture du navigateur de la façade : %v\n", err) + } +} diff --git a/internal/loki/facade_browser_test.go b/internal/loki/facade_browser_test.go new file mode 100644 index 0000000..452183b --- /dev/null +++ b/internal/loki/facade_browser_test.go @@ -0,0 +1,53 @@ +package loki + +import ( + "strings" + "testing" +) + +// Lot 44 : quand la façade tient la conversation, le navigateur est le sien — +// les outils browser_* du Go, l'aperçu et la fermeture passent par elle ; +// façade en panne, le navigateur du Go prend le relais. +func TestFacadeNavigateur(t *testing.T) { + testHome(t) + t.Setenv("LOKI_CHROME", "/nulle/part/chrome") + f := demarrerFacade(t) + f.internalStatus = 200 + f.internalBody = `{"result":"# Exemple\nhttps://exemple.test/","image":null}` + if got := toolCUOpen(map[string]any{"url": "exemple.test"}); got != "# Exemple\nhttps://exemple.test/" { + t.Fatalf("ouverture : %q", got) + } + vu := f.recu() + if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/browser jeton ") || + !strings.Contains(vu[0], `"op":"tool"`) || !strings.Contains(vu[0], `"name":"browser_open"`) || + !strings.Contains(vu[0], `"url":"exemple.test"`) { + t.Fatalf("appel : %v", vu) + } + f.internalBody = `{"result":"[ok] capture","image":{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}}` + res, img := toolCUScreenshot() + if res != "[ok] capture" || img["type"] != "image_url" { + t.Fatalf("capture : %q %v", res, img) + } + f.internalBody = `{"result":"[erreur] la vision n'est pas active","image":null}` + if res, img := toolCUScreenshot(); img != nil || !strings.HasPrefix(res, "[erreur]") { + t.Fatalf("capture refusée : %q %v", res, img) + } + f.internalBody = `{"id":"abc"}` + if id := cuAutoShot(); id != "abc" { + t.Fatalf("aperçu : %q", id) + } + f.internalBody = `{}` + cdpShutdown() + vu = f.recu() + if last := vu[len(vu)-1]; !strings.Contains(last, `"op":"shutdown"`) { + t.Fatalf("fermeture : %v", vu) + } + if !facadeChatRoutes["/api/chat/cushot"] { + t.Fatal("/api/chat/cushot n'est pas remis à la façade") + } + // Façade en panne : le Go pilote lui-même. + f.internalStatus = 500 + if got := toolCUSnapshot(); got != "[erreur] fork/exec /nulle/part/chrome: no such file or directory" { + t.Fatalf("repli : %q", got) + } +} diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 4c72ed1..43f6a22 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -91,6 +91,8 @@ var facadeChatRoutes = map[string]bool{ "/api/chat/criteria": true, // Lot 39 : l'anneau de télémétrie est celui de la façade. "/api/perf/summary": true, + // Lot 44 : les aperçus du navigateur piloté, tenu par la façade. + "/api/chat/cushot": true, } // withFacadeChat remet la requête à la façade quand elle tient la diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 15fe9d6..184110c 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -93,17 +93,17 @@ func TestFacadeRelaieLesRoutesDuChat(t *testing.T) { t.Fatalf("%s non relayé : %q", p, w.Body.String()) } } - // L'aperçu du navigateur piloté reste ici. + // L'aperçu du navigateur piloté aussi : le navigateur est le sien (lot 44). w = httptest.NewRecorder() withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil)) - if w.Body.String() != "go" { - t.Fatalf("aperçu relayé : %q", w.Body.String()) + if w.Body.String() != f.reply { + t.Fatalf("aperçu non relayé : %q", w.Body.String()) } // Sans LOKI_CHAT=rust : tout reste ici. t.Setenv(facadeChatEnv, "") w = httptest.NewRecorder() withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil)) - if w.Body.String() != "go" || len(f.recu()) != 4 { + if w.Body.String() != "go" || len(f.recu()) != 5 { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } diff --git a/internal/loki/parity_browser_test.go b/internal/loki/parity_browser_test.go new file mode 100644 index 0000000..1593a3e --- /dev/null +++ b/internal/loki/parity_browser_test.go @@ -0,0 +1,399 @@ +//go:build linux + +package loki + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + + "github.com/coder/websocket" +) + +// parity_browser_test.go — journal de référence du navigateur piloté (lot 44 +// de la migration Rust) : les outils browser_*, de bout en bout contre un faux +// Chrome. Un script tient lieu de binaire (il annonce le port d'un faux +// serveur CDP dans DevToolsActivePort) ; le faux CDP répond selon un scénario +// réglé à chaque étape et note chaque commande reçue (méthode, paramètres, +// scripts résumés par leur empreinte). Chaque étape porte le résultat, les +// commandes, et pour une capture ses dimensions et quelques pixels de la +// grille. Rangé dans rust/loki-rs/tests/parity_browser/browser.json ; +// tests/browser.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNavigateur + +const parityBrowserFile = "../../rust/loki-rs/tests/parity_browser/browser.json" + +// parityFakeChrome : le faux navigateur. Il annonce le port du faux CDP, +// range ses arguments, et attend. +const parityFakeChrome = `#!/bin/sh +for a in "$@"; do + case "$a" in + --user-data-dir=*) dir="${a#--user-data-dir=}" ;; + esac +done +printf '%s\n/devtools/browser/faux\n' "$FAKE_CDP_PORT" > "$dir/DevToolsActivePort" +printf '%s\n' "$@" > "$FAKE_CDP_ARGS" +exec sleep 600 +` + +// parityCDPScene : ce que le faux CDP répond (réglé étape par étape). +type parityCDPScene struct { + Snap any `json:"snap,omitempty"` // valeur du script de snapshot ; "" : sans valeur + Find any `json:"find,omitempty"` // valeur de browser_find + Center any `json:"center,omitempty"` // valeur de refCenter + Typed any `json:"typed,omitempty"` // valeur de typeInto + Href string `json:"href,omitempty"` + NavError string `json:"nav_error,omitempty"` + Throw bool `json:"throw,omitempty"` // les scripts lèvent une exception + Errors map[string]string `json:"errors,omitempty"` // méthode → message d'erreur CDP +} + +type parityBrowserStep struct { + Name string `json:"name"` + Scene *parityCDPScene `json:"scene,omitempty"` // remplace le scénario courant + Vision bool `json:"vision,omitempty"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` + Image map[string]any `json:"image,omitempty"` // dimensions et pixels de la capture du modèle + Saved []string `json:"saved"` // captures rangées dans le dossier de travail + Calls []string `json:"calls"` +} + +// parityCDPAbbrev résume un script : début, longueur, empreinte. +func parityCDPAbbrev(s string) string { + if len(s) <= 60 { + return s + } + h := sha256.Sum256([]byte(s)) + r := []rune(s) + head := string(r[:40]) + return fmt.Sprintf("%s…[%d:%s]", head, len(s), hex.EncodeToString(h[:6])) +} + +// parityCDPCall : une commande reçue, en une ligne stable. +func parityCDPCall(method string, params map[string]any) string { + if params == nil { + return method + } + p := map[string]any{} + for k, v := range params { + if s, ok := v.(string); ok && (k == "expression" || k == "source") { + v = parityCDPAbbrev(s) + } + p[k] = v + } + b, _ := json.Marshal(p) + return method + " " + string(b) +} + +// parityPNG : une capture unie (la grille s'y lit sans ambiguïté). +func parityPNG(w, h int) string { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 40, G: 80, B: 120, A: 255}) + } + } + var b bytes.Buffer + _ = png.Encode(&b, img) + return base64.StdEncoding.EncodeToString(b.Bytes()) +} + +// parityImageFacts : dimensions et pixels témoins d'une image relayée. +func parityImageFacts(t *testing.T, part map[string]any) map[string]any { + u, _ := part["image_url"].(map[string]any)["url"].(string) + const pre = "data:image/png;base64," + if !strings.HasPrefix(u, pre) { + t.Fatalf("image relayée : %.60s", u) + } + raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(u, pre)) + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + b := img.Bounds() + px := map[string]string{} + for _, p := range [][2]int{{0, 0}, {100, 50}, {101, 50}, {50, 100}, {2, 2}, {104, 4}, {110, 12}, {300, 799}, {1279, 700}, {4, 204}} { + if p[0] >= b.Dx() || p[1] >= b.Dy() { + continue + } + r, g, bl, a := img.At(p[0], p[1]).RGBA() + px[fmt.Sprintf("%d,%d", p[0], p[1])] = fmt.Sprintf("%d,%d,%d,%d", r>>8, g>>8, bl>>8, a>>8) + } + return map[string]any{"w": b.Dx(), "h": b.Dy(), "px": px} +} + +func TestPariteNavigateur(t *testing.T) { + testHome(t) + // Le dossier de travail est mis en cache par processus (workspaceOnce) : + // celui d'un test précédent a pu disparaître avec son TempDir. + ws := agentWorkspace() + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + tools := t.TempDir() + chrome := filepath.Join(tools, "chrome") + if err := os.WriteFile(chrome, []byte(parityFakeChrome), 0o755); err != nil { + t.Fatal(err) + } + argsFile := filepath.Join(tools, "args") + t.Setenv("LOKI_CHROME", chrome) + t.Setenv("FAKE_CDP_ARGS", argsFile) + t.Setenv("LOKI_CU_HEADFUL", "") + + var mu sync.Mutex + scene := parityCDPScene{} + var calls []string + reply := func(method string, params map[string]any) (any, string) { + mu.Lock() + defer mu.Unlock() + calls = append(calls, parityCDPCall(method, params)) + if msg, ok := scene.Errors[method]; ok { + return nil, msg + } + val := func(v any) any { + if v == "" { + return map[string]any{"result": map[string]any{"type": "undefined"}} + } + return map[string]any{"result": map[string]any{"type": "object", "value": v}} + } + switch method { + case "Runtime.evaluate": + expr, _ := params["expression"].(string) + switch { + case expr == "1": + return val(1), "" + case expr == "document.readyState": + return val("complete"), "" + case expr == "location.href": + return val(scene.Href), "" + } + if scene.Throw { + return map[string]any{"result": map[string]any{"type": "object"}, + "exceptionDetails": map[string]any{"text": "Uncaught TypeError: x is not a function"}}, "" + } + switch { + case strings.Contains(expr, "window.__lokiRefs=map"): + return val(scene.Snap), "" + case strings.Contains(expr, "var pool=ajQueryAll();"): + return val(scene.Find), "" + case strings.Contains(expr, "inline:'center'"): + return val(scene.Center), "" + case strings.Contains(expr, "return 'TEXT';"): + return val(scene.Typed), "" + case strings.HasPrefix(expr, "history.") || strings.HasPrefix(expr, "window.scrollBy("): + return val("ok"), "" + } + return val(""), "" + case "Page.navigate": + r := map[string]any{"frameId": "F1", "loaderId": "L1"} + if scene.NavError != "" { + r["errorText"] = scene.NavError + } + return r, "" + case "Page.captureScreenshot": + return map[string]any{"data": parityPNG(1280, 800)}, "" + } + return map[string]any{}, "" + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/json" { + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"type": "service_worker", "webSocketDebuggerUrl": "ws://" + r.Host + "/devtools/sw/0"}, + {"type": "page", "webSocketDebuggerUrl": "ws://" + r.Host + "/devtools/page/1"}, + }) + return + } + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + c.SetReadLimit(-1) + for { + _, data, err := c.Read(r.Context()) + if err != nil { + return + } + var in struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + _ = json.Unmarshal(data, &in) + res, errMsg := reply(in.Method, in.Params) + out := map[string]any{"id": in.ID} + if errMsg != "" { + out["error"] = map[string]any{"code": -32000, "message": errMsg} + } else { + out["result"] = res + } + b, _ := json.Marshal(out) + if err := c.Write(r.Context(), websocket.MessageText, b); err != nil { + return + } + } + })) + defer srv.Close() + t.Setenv("FAKE_CDP_PORT", strings.TrimPrefix(srv.URL, "http://127.0.0.1:")) + cdpShutdown() + t.Cleanup(cdpShutdown) + + capRe := regexp.MustCompile(`capture-[0-9]{8}-[0-9]{6}\.png`) + var steps []parityBrowserStep + run := func(st parityBrowserStep) { + mu.Lock() + if st.Scene != nil { + scene = *st.Scene + } + calls = nil + mu.Unlock() + if st.Vision { + _ = SetConfigKey("MMPROJ", "vision.gguf") + } else { + _ = SetConfigKey("MMPROJ", "") + } + args := map[string]any{} + b, _ := json.Marshal(st.Args) + _ = json.Unmarshal(b, &args) + res := execTool(t.Context(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true}) + st.Result = capRe.ReplaceAllString(res.Result, "capture-{{T}}.png") + if res.VisionImg != nil { + st.Image = parityImageFacts(t, res.VisionImg) + } + st.Saved = []string{} + entries, _ := os.ReadDir(ws) + for _, e := range entries { + if !capRe.MatchString(e.Name()) { + continue + } + st.Saved = append(st.Saved, capRe.ReplaceAllString(e.Name(), "capture-{{T}}.png")) + _ = os.Remove(filepath.Join(ws, e.Name())) + } + mu.Lock() + st.Calls = append([]string{}, calls...) + mu.Unlock() + if st.Args == nil { + st.Args = map[string]any{} + } + steps = append(steps, st) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + snapPage := map[string]any{"title": "Exemple", "url": "https://exemple.test/", "above": 0, "below": 900, + "refs": []any{`[1] link "Accueil"`, `[2] button "Créer un compte"`, `[3] textbox "Nom = Jean"`}} + snapScrolled := map[string]any{"title": "Exemple", "url": "https://exemple.test/", "above": 600, "below": 10, + "refs": []any{`[4] link "Mentions"`}} + snapEmpty := map[string]any{"title": "", "url": "about:blank", "refs": []any{}, "above": 0, "below": 0} + + run(parityBrowserStep{Name: "ouverture sans url", Tool: "browser_open", Args: a("url", " ")}) + run(parityBrowserStep{Name: "ouverture", Scene: &parityCDPScene{Snap: snapPage, Href: "https://exemple.test/"}, + Tool: "browser_open", Args: a("url", "exemple.test")}) + run(parityBrowserStep{Name: "snapshot inchangé", Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "réouverture : page complète", Tool: "browser_open", Args: a("url", "https://exemple.test/")}) + run(parityBrowserStep{Name: "page vide", Scene: &parityCDPScene{Snap: snapEmpty, Href: "about:blank"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot null", Scene: &parityCDPScene{Snap: nil, Href: "about:blank"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot sans valeur", Scene: &parityCDPScene{Snap: ""}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot illisible", Scene: &parityCDPScene{Snap: "texte"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "exception JS", Scene: &parityCDPScene{Throw: true}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "défilé", Scene: &parityCDPScene{Snap: snapScrolled, Href: "https://exemple.test/"}, + Tool: "browser_scroll", Args: a("direction", "down")}) + run(parityBrowserStep{Name: "défilement haut", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_scroll", Args: a("direction", "UP")}) + run(parityBrowserStep{Name: "défilement gauche puis droite", Tool: "browser_scroll", Args: a("direction", "left")}) + run(parityBrowserStep{Name: "défilement par défaut", Tool: "browser_scroll", Args: a()}) + run(parityBrowserStep{Name: "direction inconnue", Tool: "browser_scroll", Args: a("direction", "diagonale")}) + run(parityBrowserStep{Name: "clic", Scene: &parityCDPScene{Snap: snapScrolled, Center: map[string]any{"x": 640, "y": 412.5}}, + Tool: "browser_click", Args: a("ref", 2.0)}) + run(parityBrowserStep{Name: "clic introuvable", Scene: &parityCDPScene{Snap: snapPage, Center: nil}, Tool: "browser_click", Args: a("ref", 9.0)}) + run(parityBrowserStep{Name: "clic sans ref", Tool: "browser_click", Args: a("ref", "2")}) + run(parityBrowserStep{Name: "clic refusé", Scene: &parityCDPScene{Snap: snapPage, Center: map[string]any{"x": 1, "y": 2}, + Errors: map[string]string{"Input.dispatchMouseEvent": "Cannot dispatch"}}, Tool: "browser_click", Args: a("ref", 1.0)}) + run(parityBrowserStep{Name: "clic en coordonnées", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_click_xy", Args: a("x", 100.4, "y", 250.5)}) + run(parityBrowserStep{Name: "coordonnées manquantes", Tool: "browser_click_xy", Args: a("x", 10.0)}) + run(parityBrowserStep{Name: "saisie libre", Scene: &parityCDPScene{Snap: snapScrolled}, Tool: "browser_type", Args: a("text", "bonjour & co")}) + run(parityBrowserStep{Name: "saisie dans un champ", Scene: &parityCDPScene{Snap: snapPage, Typed: "TEXT"}, Tool: "browser_type", Args: a("text", "Jean", "ref", 3.0)}) + run(parityBrowserStep{Name: "liste déroulante", Scene: &parityCDPScene{Snap: snapScrolled, Typed: "SELECT"}, Tool: "browser_type", Args: a("text", "France", "ref", 5.0)}) + run(parityBrowserStep{Name: "option absente", Scene: &parityCDPScene{Typed: "NOOPT"}, Tool: "browser_type", Args: a("text", "Mars", "ref", 5.0)}) + run(parityBrowserStep{Name: "champ introuvable", Scene: &parityCDPScene{Typed: "NOTFOUND"}, Tool: "browser_type", Args: a("text", "x", "ref", 7.0)}) + run(parityBrowserStep{Name: "touche", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_key", Args: a("key", " Enter ")}) + run(parityBrowserStep{Name: "touche esc", Tool: "browser_key", Args: a("key", "esc")}) + run(parityBrowserStep{Name: "touche inconnue", Tool: "browser_key", Args: a("key", "F13")}) + run(parityBrowserStep{Name: "recherche vide", Tool: "browser_find", Args: a("text", "")}) + run(parityBrowserStep{Name: "recherche", Scene: &parityCDPScene{Snap: snapPage, Find: map[string]any{"refs": []any{`[2] button "Créer un compte"`, `[7] link "Créer"`}}}, + Tool: "browser_find", Args: a("text", "créer ")}) + run(parityBrowserStep{Name: "recherche sans résultat", Scene: &parityCDPScene{Find: map[string]any{"refs": []any{}}}, Tool: "browser_find", Args: a("text", "zzz")}) + run(parityBrowserStep{Name: "navigation en échec", Scene: &parityCDPScene{Snap: snapPage, Href: "https://nulle-part.test/", NavError: "net::ERR_NAME_NOT_RESOLVED"}, + Tool: "browser_open", Args: a("url", "nulle-part.test")}) + run(parityBrowserStep{Name: "page d'erreur", Scene: &parityCDPScene{Snap: snapPage, Href: "chrome-error://chromewebdata/"}, + Tool: "browser_open", Args: a("url", "http://x.test")}) + run(parityBrowserStep{Name: "navigation interrompue bénigne", Scene: &parityCDPScene{Snap: snapPage, Href: "https://dl.test/f.zip", NavError: "net::ERR_ABORTED"}, + Tool: "browser_open", Args: a("url", "https://dl.test/f.zip")}) + run(parityBrowserStep{Name: "retour", Scene: &parityCDPScene{Snap: snapScrolled, Href: "https://exemple.test/"}, Tool: "browser_open", Args: a("url", " Back ")}) + run(parityBrowserStep{Name: "avance", Tool: "browser_open", Args: a("url", "forward")}) + run(parityBrowserStep{Name: "commande refusée", Scene: &parityCDPScene{Snap: snapPage, Errors: map[string]string{"Page.navigate": "Cannot navigate to invalid URL"}}, + Tool: "browser_open", Args: a("url", "https://exemple.test/")}) + run(parityBrowserStep{Name: "capture sans vision", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_screenshot"}) + run(parityBrowserStep{Name: "capture", Vision: true, Tool: "browser_screenshot"}) + run(parityBrowserStep{Name: "capture refusée", Vision: true, Scene: &parityCDPScene{Errors: map[string]string{"Page.captureScreenshot": "Unable to capture"}}, Tool: "browser_screenshot"}) + long := []any{} + for i := 1; i <= 200; i++ { + long = append(long, fmt.Sprintf(`[%d] link "Un lien assez long pour remplir la sortie, numéro %d"`, i, i)) + } + run(parityBrowserStep{Name: "sortie plafonnée", Scene: &parityCDPScene{Snap: map[string]any{"title": "Long", "url": "https://long.test/", "refs": long}}, Tool: "browser_snapshot"}) + + // Le lancement : arguments du faux navigateur. + b, _ := os.ReadFile(argsFile) + launch := []string{} + for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + if strings.HasPrefix(l, "--user-data-dir=") { + l = "--user-data-dir={{PROFIL}}" + } + launch = append(launch, l) + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"launch": launch, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityBrowserFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBrowserFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBrowserFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("le navigateur piloté ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityBrowserFile) + } +} diff --git a/rust/loki-rs/src/browser/grid.rs b/rust/loki-rs/src/browser/grid.rs new file mode 100644 index 0000000..5ade864 --- /dev/null +++ b/rust/loki-rs/src/browser/grid.rs @@ -0,0 +1,126 @@ +//! Grille de coordonnées sur la capture envoyée au modèle — port de +//! `browser_grid.go`. Lignes graduées tous les 100 px et étiquettes en +//! police bitmap 3×5 : un petit modèle y lit x/y pour `browser_click_xy`. +//! Mêmes pixels que le Go (l'encodeur PNG, lui, diffère). + +use image::{ImageFormat, Rgba, RgbaImage}; + +/// `gridStep`. +const GRID_STEP: u32 = 100; + +/// `digits5x3`. +fn glyph(c: char) -> Option<[&'static str; 5]> { + Some(match c { + '0' => ["###", "# #", "# #", "# #", "###"], + '1' => [" # ", "## ", " # ", " # ", "###"], + '2' => ["###", " #", "###", "# ", "###"], + '3' => ["###", " #", "###", " #", "###"], + '4' => ["# #", "# #", "###", " #", " #"], + '5' => ["###", "# ", "###", " #", "###"], + '6' => ["###", "# ", "###", "# #", "###"], + '7' => ["###", " #", " #", " #", " #"], + '8' => ["###", "# #", "###", "# #", "###"], + '9' => ["###", "# #", "###", " #", "###"], + _ => return None, + }) +} + +const LINE: [u8; 4] = [255, 0, 200, 130]; +const LABEL_BG: [u8; 4] = [0, 0, 0, 200]; +const LABEL_FG: [u8; 4] = [255, 255, 0, 255]; + +/// `overlayGrid` : le PNG avec sa grille ; illisible, rendu tel quel. +pub fn overlay_grid(raw: &[u8]) -> Vec { + let Ok(src) = image::load_from_memory_with_format(raw, ImageFormat::Png) else { + return raw.to_vec(); + }; + let mut img = src.to_rgba8(); + draw(&mut img); + let mut out = std::io::Cursor::new(Vec::new()); + if img.write_to(&mut out, ImageFormat::Png).is_err() { + return raw.to_vec(); + } + out.into_inner() +} + +/// La grille et ses graduations, dessinées sur l'image. +pub fn draw(img: &mut RgbaImage) { + let (w, h) = img.dimensions(); + let mut x = GRID_STEP; + while x < w { + for y in 0..h { + blend(img, x as i64, y as i64, LINE); + } + draw_label(img, x as i64 + 2, 2, &x.to_string()); + x += GRID_STEP; + } + let mut y = GRID_STEP; + while y < h { + for x in 0..w { + blend(img, x as i64, y as i64, LINE); + } + draw_label(img, 2, y as i64 + 2, &y.to_string()); + y += GRID_STEP; + } +} + +fn inside(img: &RgbaImage, x: i64, y: i64) -> bool { + x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() +} + +/// `blend` : couleur translucide sur un pixel (opaque ensuite). +fn blend(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) { + if !inside(img, x, y) { + return; + } + let a = f64::from(c[3]) / 255.0; + let o = img.get_pixel(x as u32, y as u32).0; + let mix = |n: u8, o: u8| (f64::from(n) * a + f64::from(o) * (1.0 - a)) as u8; + img.put_pixel( + x as u32, + y as u32, + Rgba([mix(c[0], o[0]), mix(c[1], o[1]), mix(c[2], o[2]), 255]), + ); +} + +fn set_opaque(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) { + if inside(img, x, y) { + img.put_pixel(x as u32, y as u32, Rgba([c[0], c[1], c[2], 255])); + } +} + +/// `drawLabel` : un nombre en police 3×5 (×2) sur fond sombre. +fn draw_label(img: &mut RgbaImage, x: i64, y: i64, s: &str) { + const SC: i64 = 2; + let (glyph_w, glyph_h, gap) = (3 * SC, 5 * SC, SC); + let box_w = s.chars().count() as i64 * (glyph_w + gap) + gap; + let box_h = glyph_h + 2 * gap; + for dy in -gap..box_h - gap { + for dx in -gap..box_w - gap { + set_opaque(img, x + dx, y + dy, LABEL_BG); + } + } + let mut cx = x; + for c in s.chars() { + if let Some(g) = glyph(c) { + for (row, line) in g.iter().enumerate() { + for (col, b) in line.bytes().enumerate() { + if b != b'#' { + continue; + } + for iy in 0..SC { + for ix in 0..SC { + set_opaque( + img, + cx + col as i64 * SC + ix, + y + row as i64 * SC + iy, + LABEL_FG, + ); + } + } + } + } + } + cx += glyph_w + gap; + } +} diff --git a/rust/loki-rs/src/browser/js.rs b/rust/loki-rs/src/browser/js.rs new file mode 100644 index 0000000..19f2ac2 --- /dev/null +++ b/rust/loki-rs/src/browser/js.rs @@ -0,0 +1,69 @@ +//! Scripts injectés dans la page (copiés de `computer_cdp.go`, à l'octet près : +//! le journal de parité compare les expressions envoyées). + +/// `cdpSameTabJS` : les navigations « nouvel onglet » restent dans l'onglet. +pub const SAME_TAB_JS: &str = r#"(function(){try{ + window.open=function(u){if(u)location.href=u;return window;}; + document.addEventListener('click',function(e){ + var a=e.target&&e.target.closest&&e.target.closest('a[target]'); + if(a)a.removeAttribute('target'); + },true); +}catch(e){}})();"#; + +/// `cdpHelpersJS` : rôle, nom, actionnable, visible, shadow DOM. +pub const HELPERS_JS: &str = r#" +function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;} +function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);} +function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;} +function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;} +var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]'; +// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics, +// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le +// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll / +// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert. +function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;imaxRef)maxRef=v;} + var els=pool.filter(ajActionable); + var refs=[],count=0,first=null; + for(var k=0;k String { + format!("{HELPERS_JS}{SNAPSHOT_TAIL_JS}") +} diff --git a/rust/loki-rs/src/browser/mod.rs b/rust/loki-rs/src/browser/mod.rs new file mode 100644 index 0000000..b4e4f64 --- /dev/null +++ b/rust/loki-rs/src/browser/mod.rs @@ -0,0 +1,1114 @@ +//! Navigateur piloté (lot 44 de la migration) — port de `computer_cdp.go`, +//! `computer_use.go`, `browser_grid.go` et `cu_autoshot.go`. +//! +//! Un Chromium/Chrome local piloté par le protocole DevTools (CDP) : la +//! façade tient la session (une seule, lancée à la demande et gardée entre +//! les appels), exécute les outils `browser_*` de ses tours et garde +//! l'anneau des aperçus montrés à l'utilisateur. Le Go, quand elle tient la +//! conversation, lui passe ses propres appels (sous-agents, tâches IA, +//! ligne de commande) : un seul navigateur. + +pub mod grid; +pub mod js; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use futures_util::{SinkExt as _, StreamExt as _}; +use serde_json::{Map, Value, json}; +use tokio::sync::{mpsc, oneshot}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// `cuViewW`, `cuViewH` : le viewport (et donc la capture), fixe. +pub const VIEW_W: i64 = 1280; +pub const VIEW_H: i64 = 800; +/// `cuMaxOutput`. +pub const MAX_OUTPUT: usize = 8000; +/// `cuShotRing` : aperçus gardés en mémoire. +const SHOT_RING: usize = 8; + +/// Les outils servis ici. +pub fn is_tool(name: &str) -> bool { + matches!( + name, + "browser_open" + | "browser_snapshot" + | "browser_find" + | "browser_click" + | "browser_click_xy" + | "browser_type" + | "browser_key" + | "browser_scroll" + | "browser_screenshot" + ) +} + +/// `cuAutoShotTool` : les outils après lesquels la page est capturée pour +/// l'aperçu. +pub fn auto_shot_tool(name: &str) -> bool { + matches!( + name, + "browser_open" + | "browser_click" + | "browser_click_xy" + | "browser_type" + | "browser_key" + | "browser_scroll" + | "browser_find" + ) +} + +/// `capCUOutput`. +pub fn cap_output(s: &str) -> String { + match s.char_indices().nth(MAX_OUTPUT) { + None => s.to_string(), + Some((i, _)) => format!("{}\n…[tronqué]", &s[..i]), + } +} + +/// Un nombre comme `json.Marshal` l'écrit (`640`, pas `640.0`). +fn go_num(f: f64) -> Value { + if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e15 { + json!(f as i64) + } else { + json!(f) + } +} + +// --- Session CDP --------------------------------------------------------------- + +type Reply = Result; + +/// `cdpSession` : le processus du navigateur, la connexion à la cible +/// « page » et le multiplexage requête/réponse. +pub struct Session { + child: Mutex>, + user_dir: PathBuf, + out: mpsc::UnboundedSender, + pending: Arc>>>, + next: AtomicI64, + /// Requêtes réseau en vol et dernier événement réseau (`settle`). + net: Arc)>>, + last_snap: Mutex, + closed: Arc, + tasks: Mutex>>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// `sweepStaleCUDirs` : les profils temporaires abandonnés (plus d'une heure). +fn sweep_stale(tmp: &Path) { + let Ok(rd) = std::fs::read_dir(tmp) else { + return; + }; + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !name.starts_with("loki-cu-") || !e.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + if e.metadata() + .and_then(|m| m.modified()) + .is_ok_and(|t| t.elapsed().is_ok_and(|d| d > Duration::from_secs(3600))) + { + let _ = std::fs::remove_dir_all(e.path()); + } + } +} + +/// `os.MkdirTemp(tmp, "loki-cu-")`. +fn mkdtemp(tmp: &Path) -> Result { + for _ in 0..16 { + let mut b = [0u8; 5]; + let _ = getrandom::fill(&mut b); + let n = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], 0, 0, 0]) % 10_000_000_000; + let p = tmp.join(format!("loki-cu-{n}")); + match std::fs::create_dir(&p) { + Ok(()) => return Ok(p), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(crate::paths::os_err( + "mkdirtemp", + &tmp.join("loki-cu-*").to_string_lossy(), + &e, + )); + } + } + } + Err("mkdirtemp: trop d'essais".into()) +} + +/// Les arguments de lancement du navigateur. +pub fn launch_args(user_dir: &Path, headful: bool) -> Vec { + let mut a: Vec = Vec::new(); + if !headful { + a.push("--headless=new".into()); + } + a.extend([ + "--remote-debugging-port=0".to_string(), + format!("--user-data-dir={}", user_dir.to_string_lossy()), + "--no-first-run".into(), + "--no-default-browser-check".into(), + "--disable-gpu".into(), + "--disable-extensions".into(), + "--disable-background-networking".into(), + "--disable-features=Translate,BackForwardCache".into(), + "--no-sandbox".into(), + "--disable-dev-shm-usage".into(), + "--window-size=1280,900".into(), + "about:blank".into(), + ]); + a +} + +/// `cdpPageWS` : l'URL websocket d'une cible « page ». +async fn page_ws(port: &str) -> Result { + crate::chat::engine::install_crypto(); + let cl = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(resp) = cl.get(format!("http://127.0.0.1:{port}/json")).send().await { + let body = resp.bytes().await.unwrap_or_default(); + let targets: Vec = serde_json::from_slice(&body).unwrap_or_default(); + for t in &targets { + let ws = t + .get("webSocketDebuggerUrl") + .and_then(Value::as_str) + .unwrap_or(""); + if t.get("type").and_then(Value::as_str) == Some("page") && !ws.is_empty() { + return Ok(ws.to_string()); + } + } + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + Err("aucune cible 'page' exposée par Chrome".into()) +} + +impl Session { + /// `cdpLaunch`. + async fn launch(bin: &str, tmp: &Path) -> Result, String> { + if bin.is_empty() { + return Err("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=".into()); + } + sweep_stale(tmp); + let user_dir = mkdtemp(tmp)?; + let headful = std::env::var("LOKI_CU_HEADFUL").is_ok_and(|v| !v.is_empty()); + let mut cmd = tokio::process::Command::new(bin); + cmd.args(launch_args(&user_dir, headful)) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + // Tué avec la façade, même brutalement (`prepareBoundChild`). + #[cfg(target_os = "linux")] + // SAFETY: prctl(2) est async-signal-safe, seul appel entre fork et exec. + unsafe { + cmd.pre_exec(|| { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + Ok(()) + }); + } + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = std::fs::remove_dir_all(&user_dir); + return Err(crate::paths::os_err("fork/exec", bin, &e)); + } + }; + // Le port réel, en tête de DevToolsActivePort. + let port_file = user_dir.join("DevToolsActivePort"); + let deadline = Instant::now() + Duration::from_secs(15); + let mut port = String::new(); + while Instant::now() < deadline { + if let Ok(b) = std::fs::read_to_string(&port_file) { + let line = b.trim().split('\n').next().unwrap_or("").to_string(); + if !line.is_empty() { + port = line; + break; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let fail = |child: &mut tokio::process::Child, e: String| { + let _ = child.start_kill(); + let _ = std::fs::remove_dir_all(&user_dir); + Err(e) + }; + if port.is_empty() { + return fail( + &mut child, + "Chrome n'a pas exposé son port de debug (DevToolsActivePort)".into(), + ); + } + let ws_url = match page_ws(&port).await { + Ok(u) => u, + Err(e) => return fail(&mut child, e), + }; + let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(None) + .max_frame_size(None); + let ws = match tokio_tungstenite::connect_async_with_config(&ws_url, Some(cfg), false).await + { + Ok((ws, _)) => ws, + Err(e) => return fail(&mut child, format!("failed to WebSocket dial: {e}")), + }; + let (mut sink, mut stream) = ws.split(); + let (out, mut out_rx) = mpsc::unbounded_channel::(); + let pending: Arc>>> = Default::default(); + let net: Arc)>> = Default::default(); + let closed = Arc::new(AtomicBool::new(false)); + let writer = tokio::spawn(async move { + while let Some(m) = out_rx.recv().await { + if sink.send(WsMessage::Text(m.into())).await.is_err() { + break; + } + } + let _ = sink.close().await; + }); + let (p2, n2, c2) = (pending.clone(), net.clone(), closed.clone()); + let reader = tokio::spawn(async move { + while let Some(Ok(msg)) = stream.next().await { + let data = match msg { + WsMessage::Text(t) => t.as_bytes().to_vec(), + WsMessage::Binary(b) => b.to_vec(), + WsMessage::Close(_) => break, + _ => continue, + }; + let Ok(m) = serde_json::from_slice::(&data) else { + continue; + }; + let id = m.get("id").and_then(Value::as_i64).unwrap_or(0); + if id != 0 { + let tx = lock(&p2).remove(&id); + if let Some(tx) = tx { + let reply = match m.get("error").filter(|e| !e.is_null()) { + Some(e) => Err(e + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string()), + None => Ok(m.get("result").cloned().unwrap_or(Value::Null)), + }; + let _ = tx.send(reply); + } + continue; + } + match m.get("method").and_then(Value::as_str) { + Some("Network.requestWillBeSent") => { + let mut n = lock(&n2); + n.0 += 1; + n.1 = Some(Instant::now()); + } + Some("Network.loadingFinished" | "Network.loadingFailed") => { + let mut n = lock(&n2); + if n.0 > 0 { + n.0 -= 1; + } + n.1 = Some(Instant::now()); + } + _ => {} + } + } + c2.store(true, Ordering::SeqCst); + lock(&p2).clear(); + }); + let s = Arc::new(Session { + child: Mutex::new(Some(child)), + user_dir, + out, + pending, + next: AtomicI64::new(0), + net, + last_snap: Mutex::new(String::new()), + closed, + tasks: Mutex::new(vec![writer, reader]), + }); + for m in [ + "Page.enable", + "Runtime.enable", + "DOM.enable", + "Network.enable", + ] { + let _ = s.call(m, None).await; + } + let _ = s + .call( + "Emulation.setDeviceMetricsOverride", + Some(json!({ "width": VIEW_W, "height": VIEW_H, "deviceScaleFactor": 1, "mobile": false })), + ) + .await; + let _ = s + .call( + "Page.addScriptToEvaluateOnNewDocument", + Some(json!({ "source": js::SAME_TAB_JS })), + ) + .await; + Ok(s) + } + + /// `call` : une commande et sa réponse (30 s au plus). + pub async fn call(&self, method: &str, params: Option) -> Result { + let id = self.next.fetch_add(1, Ordering::SeqCst) + 1; + let (tx, rx) = oneshot::channel(); + lock(&self.pending).insert(id, tx); + let mut payload = Map::new(); + payload.insert("id".into(), json!(id)); + payload.insert("method".into(), json!(method)); + if let Some(p) = params { + payload.insert("params".into(), p); + } + if self.closed.load(Ordering::SeqCst) + || self + .out + .send(crate::gojson::marshal(&Value::Object(payload))) + .is_err() + { + lock(&self.pending).remove(&id); + return Err("connexion CDP fermée".into()); + } + match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(Ok(v))) => Ok(v), + Ok(Ok(Err(msg))) => Err(format!("{method}: {msg}")), + Ok(Err(_)) => Err("connexion CDP fermée".into()), + Err(_) => { + lock(&self.pending).remove(&id); + Err(format!("{method}: délai dépassé")) + } + } + } + + /// `close`. + fn close(&self) { + for t in lock(&self.tasks).drain(..) { + t.abort(); + } + if let Some(mut c) = lock(&self.child).take() { + let _ = c.start_kill(); + } + let _ = std::fs::remove_dir_all(&self.user_dir); + } + + /// `eval` : la valeur d'une expression (`None` : pas de valeur). + pub async fn eval(&self, expr: &str) -> Result, String> { + let res = self + .call( + "Runtime.evaluate", + Some(json!({ "expression": expr, "returnByValue": true, "awaitPromise": true })), + ) + .await?; + if let Some(d) = res.get("exceptionDetails").filter(|d| d.is_object()) { + return Err(format!( + "JS: {}", + d.get("text").and_then(Value::as_str).unwrap_or("") + )); + } + Ok(res.get("result").and_then(|r| r.get("value")).cloned()) + } + + /// `snapshot` : les éléments interactifs visibles, numérotés. + pub async fn snapshot(&self) -> Result { + let val = self.eval(&js::snapshot_js()).await?; + let snap = match val { + None => return Err("snapshot illisible: unexpected end of JSON input".into()), + Some(Value::Null) => Value::Object(Map::new()), + Some(o @ Value::Object(_)) => o, + Some(other) => { + return Err(format!( + "snapshot illisible: json: cannot unmarshal {} into Go value of type loki.cuSnap", + go_kind(&other) + )); + } + }; + let s = |k: &str| { + snap.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let n = |k: &str| snap.get(k).and_then(Value::as_i64).unwrap_or(0); + let refs: Vec = snap + .get("refs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| r.as_str().unwrap_or("").to_string()) + .collect(); + let mut b = format!("# {}\n{}\n\n", s("title"), s("url")); + if refs.is_empty() { + b.push_str("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)"); + } else { + b.push_str(&format!( + "{} élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", + refs.len() + )); + b.push_str(&refs.join("\n")); + } + let (above, below) = (n("above"), n("below")); + if below > 20 || above > 20 { + b.push_str("\n\n"); + if below > 20 { + b.push_str(&format!("↓ encore ~{below} px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ")); + } + if above > 20 { + b.push_str(&format!( + "↑ ~{above} px au-dessus — browser_scroll(up) pour y revenir." + )); + } + } + Ok(b.trim_end_matches(' ').to_string()) + } + + /// `snapshotDedup` : une ligne courte si rien n'a changé. + pub async fn snapshot_dedup(&self) -> Result { + let full = self.snapshot().await?; + let mut last = lock(&self.last_snap); + if *last == full { + return Ok( + "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)" + .into(), + ); + } + *last = full.clone(); + Ok(full) + } + + /// `find` : les éléments dont le rôle ou le nom contient la requête. + pub async fn find(&self, query: &str) -> Result { + let expr = format!( + "{}({})({})", + js::HELPERS_JS, + js::FIND_JS, + crate::gojson::marshal(query) + ); + let val = self.eval(&expr).await?; + let refs: Vec = match val { + None => return Err("recherche illisible: unexpected end of JSON input".into()), + Some(v) => v + .get("refs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| r.as_str().unwrap_or("").to_string()) + .collect(), + }; + if refs.is_empty() { + return Ok(format!( + "Aucun élément contenant « {query} » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien)." + )); + } + Ok(format!( + "{} élément(s) contenant « {query} » (le 1er est amené dans la vue) — clique par numéro :\n{}", + refs.len(), + refs.join("\n") + )) + } + + /// `waitLoad` : chargement fini, puis réseau au calme. + async fn wait_load(&self) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(Value::String(s))) = self.eval("document.readyState").await + && s == "complete" + { + break; + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + self.settle().await; + } + + /// `settle` : plus de requête en vol depuis 600 ms (6 s au plus). + async fn settle(&self) { + let quiet = Duration::from_millis(600); + let deadline = Instant::now() + Duration::from_secs(6); + while Instant::now() < deadline { + let (inflight, last) = *lock(&self.net); + if inflight <= 0 && last.is_none_or(|t| t.elapsed() >= quiet) { + return; + } + tokio::time::sleep(Duration::from_millis(120)).await; + } + } + + /// `navigate` : une URL, « back » ou « forward ». + pub async fn navigate(&self, url: &str) -> Result<(), String> { + lock(&self.last_snap).clear(); + match url.trim().to_lowercase().as_str() { + "back" => { + let r = self.eval("history.back();'ok'").await; + self.wait_load().await; + return r.map(|_| ()); + } + "forward" => { + let r = self.eval("history.forward();'ok'").await; + self.wait_load().await; + return r.map(|_| ()); + } + _ => {} + } + let url = if url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + }; + let res = self + .call("Page.navigate", Some(json!({ "url": url }))) + .await?; + let error_text = res + .get("errorText") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + self.wait_load().await; + let loc = self + .eval("location.href") + .await + .ok() + .flatten() + .map(|v| v.to_string()) + .unwrap_or_default(); + let failed = loc.contains("chrome-error") + || (!error_text.is_empty() && error_text != "net::ERR_ABORTED"); + if failed { + let reason = if error_text.is_empty() { + "page injoignable".to_string() + } else { + error_text + }; + return Err(format!( + "chargement de {url} échoué ({reason}) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page" + )); + } + Ok(()) + } + + /// `refCenter` : le centre d'un élément marqué, amené au milieu. + async fn ref_center(&self, r: i64) -> Result<(f64, f64), String> { + let expr = format!( + "{}(function(){{var el=ajByRef({r});if(!el){{return null;}}el.scrollIntoView({{block:'center',inline:'center'}});var r=el.getBoundingClientRect();return {{x:r.left+r.width/2,y:r.top+r.height/2}};}})()", + js::HELPERS_JS + ); + let val = self.eval(&expr).await?; + let p = match val { + None | Some(Value::Null) => { + return Err(format!( + "élément [{r}] introuvable — refais un browser_snapshot (la page a peut-être changé)" + )); + } + Some(p) => p, + }; + let f = |k: &str| { + p.as_object() + .and_then(|o| { + o.get(k).or_else(|| { + o.iter() + .find(|(n, _)| n.eq_ignore_ascii_case(k)) + .map(|(_, v)| v) + }) + }) + .and_then(Value::as_f64) + .unwrap_or(0.0) + }; + Ok((f("x"), f("y"))) + } + + async fn mouse_click(&self, x: f64, y: f64) -> Result<(), String> { + for typ in ["mousePressed", "mouseReleased"] { + self.call( + "Input.dispatchMouseEvent", + Some(json!({ "type": typ, "x": go_num(x), "y": go_num(y), "button": "left", "clickCount": 1 })), + ) + .await?; + } + self.wait_load().await; + Ok(()) + } + + /// `clickRef`. + pub async fn click_ref(&self, r: i64) -> Result<(), String> { + let (x, y) = self.ref_center(r).await?; + self.mouse_click(x, y).await + } + + /// `clickXY`. + pub async fn click_xy(&self, x: f64, y: f64) -> Result<(), String> { + self.mouse_click(x, y).await + } + + /// `typeText`. + pub async fn type_text(&self, text: &str) -> Result<(), String> { + self.call("Input.insertText", Some(json!({ "text": text }))) + .await + .map(|_| ()) + } + + /// `typeInto` : un champ, ou l'option d'une liste déroulante native. + pub async fn type_into(&self, r: i64, text: &str) -> Result<(), String> { + let expr = format!( + r#"{}(function(ref,val){{ + var el=ajByRef(ref); + if(!el)return 'NOTFOUND'; + el.scrollIntoView({{block:'center'}}); + if(el.tagName.toLowerCase()==='select'){{ + var o=el.options,lv=val.toLowerCase(),k=-1; + for(var i=0;i=0||(o[i].value||'').toLowerCase()===lv){{k=i;break;}}}} + if(k<0)return 'NOOPT'; + el.selectedIndex=k;el.dispatchEvent(new Event('input',{{bubbles:true}}));el.dispatchEvent(new Event('change',{{bubbles:true}})); + return 'SELECT'; + }} + el.focus(); + try{{if(typeof el.select==='function')el.select();else if(window.getSelection){{var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}}}catch(e){{}} + return 'TEXT'; +}})({r},{})"#, + js::HELPERS_JS, + crate::gojson::marshal(text) + ); + let val = self.eval(&expr).await?; + let v = val.map(|v| v.to_string()).unwrap_or_default(); + match v.trim_matches('"') { + "NOTFOUND" => Err(format!( + "champ [{r}] introuvable — refais un browser_snapshot" + )), + "NOOPT" => Err(format!( + "aucune option de la liste [{r}] ne correspond à « {text} » — fais un browser_screenshot pour voir les choix" + )), + "SELECT" => Ok(()), + _ => self.type_text(text).await, + } + } + + /// `pressKey`. + pub async fn press_key(&self, name: &str) -> Result<(), String> { + let (key, code, vk) = match name.trim().to_lowercase().as_str() { + "enter" => ("Enter", "Enter", 13), + "tab" => ("Tab", "Tab", 9), + "backspace" => ("Backspace", "Backspace", 8), + "escape" | "esc" => ("Escape", "Escape", 27), + "delete" => ("Delete", "Delete", 46), + "up" => ("ArrowUp", "ArrowUp", 38), + "down" => ("ArrowDown", "ArrowDown", 40), + "left" => ("ArrowLeft", "ArrowLeft", 37), + "right" => ("ArrowRight", "ArrowRight", 39), + "home" => ("Home", "Home", 36), + "end" => ("End", "End", 35), + "pageup" => ("PageUp", "PageUp", 33), + "pagedown" => ("PageDown", "PageDown", 34), + _ => { + return Err(format!( + "touche inconnue « {name} » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown" + )); + } + }; + for typ in ["rawKeyDown", "keyUp"] { + self.call( + "Input.dispatchKeyEvent", + Some(json!({ "type": typ, "key": key, "code": code, + "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk })), + ) + .await?; + } + self.wait_load().await; + Ok(()) + } + + /// `scroll`. + pub async fn scroll(&self, dir: &str) -> Result<(), String> { + let (dx, dy) = match dir.trim().to_lowercase().as_str() { + "down" | "" => (0, 600), + "up" => (0, -600), + "right" => (600, 0), + "left" => (-600, 0), + _ => { + return Err(format!( + "direction inconnue « {dir} » (up, down, left, right)" + )); + } + }; + self.eval(&format!("window.scrollBy({dx},{dy});'ok'")) + .await?; + tokio::time::sleep(Duration::from_millis(200)).await; + Ok(()) + } + + /// `screenshot` : le viewport en PNG. + pub async fn screenshot(&self) -> Result, String> { + let res = self + .call("Page.captureScreenshot", Some(json!({ "format": "png" }))) + .await?; + let data = res.get("data").and_then(Value::as_str).unwrap_or(""); + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("illegal base64 data: {e}")) + } +} + +fn go_kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +// --- Le navigateur de la façade ------------------------------------------------- + +/// Le résultat d'un outil du navigateur. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Outcome { + pub result: String, + /// `browser_screenshot` : la partie image relayée au modèle. + pub image: Option, +} + +/// L'anneau des aperçus : ordre d'arrivée, JPEG par identifiant. +type ShotRing = (Vec, HashMap>); + +/// La session du navigateur et l'anneau des aperçus. +#[derive(Default)] +pub struct Browser { + cur: tokio::sync::Mutex>>, + shots: Mutex, + /// Binaire et dossier des profils imposés (tests) ; sinon + /// `chrome_path` et le dossier temporaire du système. + imposed: Mutex<(Option, Option)>, +} + +impl Browser { + /// Pour les tests : binaire et dossier des profils imposés. + pub fn with(chrome: &str, tmp: PathBuf) -> Self { + let b = Browser::default(); + b.impose(chrome, tmp); + b + } + + /// Impose le binaire et le dossier des profils (tests). + pub fn impose(&self, chrome: &str, tmp: PathBuf) { + *lock(&self.imposed) = (Some(chrome.to_string()), Some(tmp)); + } + + /// Le dernier aperçu de l'anneau. + pub fn last_shot(&self) -> Option { + lock(&self.shots).0.last().cloned() + } +} + +/// Le navigateur de ce processus. +pub fn global() -> Arc { + static G: std::sync::OnceLock> = std::sync::OnceLock::new(); + G.get_or_init(Default::default).clone() +} + +fn int_arg(args: &Map, k: &str) -> Option { + args.get(k) + .and_then(Value::as_f64) + .map(crate::webtools::go_int) +} + +fn arg_str(args: &Map, k: &str) -> String { + args.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +impl Browser { + /// `cdpGet` : la session, (re)lancée au besoin ; un ping vérifie + /// qu'une session existante répond encore. + pub async fn get(&self) -> Result, String> { + let mut cur = self.cur.lock().await; + if let Some(s) = cur.as_ref() { + if s.call( + "Runtime.evaluate", + Some(json!({ "expression": "1", "returnByValue": true })), + ) + .await + .is_ok() + { + return Ok(s.clone()); + } + s.close(); + *cur = None; + } + let (chrome, tmp) = lock(&self.imposed).clone(); + let bin = chrome.unwrap_or_else(crate::system::chrome_path); + let tmp = tmp.unwrap_or_else(std::env::temp_dir); + let s = Session::launch(&bin, &tmp).await?; + *cur = Some(s.clone()); + Ok(s) + } + + /// `cdpShutdown`. + pub async fn shutdown(&self) { + if let Some(s) = self.cur.lock().await.take() { + s.close(); + } + } + + /// Une session est-elle ouverte ? + pub async fn running(&self) -> bool { + self.cur.lock().await.is_some() + } + + /// Un outil `browser_*` (`None` pour un autre). `vision` : le modèle + /// voit-il les images ; `workspace` : où ranger la capture. + pub async fn exec( + &self, + name: &str, + args: &Map, + vision: bool, + workspace: &str, + ) -> Option { + let result = match name { + "browser_open" => self.tool_open(args).await, + "browser_snapshot" => self.tool_snapshot().await, + "browser_find" => self.tool_find(args).await, + "browser_click" => self.tool_click(args).await, + "browser_click_xy" => self.tool_click_xy(args).await, + "browser_type" => self.tool_type(args).await, + "browser_key" => self.tool_key(args).await, + "browser_scroll" => self.tool_scroll(args).await, + "browser_screenshot" => return Some(self.tool_screenshot(vision, workspace).await), + _ => return None, + }; + Some(Outcome { + result: cap_output(&result), + image: None, + }) + } + + async fn tool_open(&self, args: &Map) -> String { + let url = arg_str(args, "url"); + if url.trim().is_empty() { + return "[erreur] url manquante".into(); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.navigate(&url).await { + return format!("[erreur] navigation : {e}"); + } + s.snapshot_dedup() + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_snapshot(&self) -> String { + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + s.snapshot_dedup() + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_find(&self, args: &Map) -> String { + let text = arg_str(args, "text"); + if text.trim().is_empty() { + return "[erreur] texte de recherche manquant".into(); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + s.find(&text) + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_click(&self, args: &Map) -> String { + let Some(r) = int_arg(args, "ref") else { + return "[erreur] ref manquant (numéro de l'élément)".into(); + }; + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.click_ref(r).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] cliqué [{r}]\n\n{snap}") + } + + async fn tool_type(&self, args: &Map) -> String { + let text = arg_str(args, "text"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + let r = match int_arg(args, "ref") { + Some(r) => s.type_into(r, &text).await, + None => s.type_text(&text).await, + }; + if let Err(e) = r { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] saisi « {text} »\n\n{snap}") + } + + async fn tool_key(&self, args: &Map) -> String { + let key = arg_str(args, "key"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.press_key(&key).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] touche {key}\n\n{snap}") + } + + async fn tool_click_xy(&self, args: &Map) -> String { + let (Some(x), Some(y)) = ( + args.get("x").and_then(Value::as_f64), + args.get("y").and_then(Value::as_f64), + ) else { + return "[erreur] coordonnées x et y requises (en pixels du screenshot)".into(); + }; + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.click_xy(x, y).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] clic en ({x:.0}, {y:.0})\n\n{snap}") + } + + async fn tool_scroll(&self, args: &Map) -> String { + let dir = arg_str(args, "direction"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.scroll(&dir).await { + return format!("[erreur] {e}"); + } + s.snapshot_dedup().await.unwrap_or_default() + } + + /// `toolCUScreenshot` : la capture, rangée propre dans le dossier de + /// travail ; celle du modèle porte la grille. + async fn tool_screenshot(&self, vision: bool, workspace: &str) -> Outcome { + let err = |e: String| Outcome { + result: format!("[erreur] {e}"), + image: None, + }; + if !vision { + return err( + "la vision n'est pas active sur ce modèle — impossible de capturer l'écran".into(), + ); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return err(e), + }; + let png = match s.screenshot().await { + Ok(p) => p, + Err(e) => return err(e), + }; + let mut saved = String::new(); + let name = format!("capture-{}.png", local_stamp()); + if !workspace.is_empty() && std::fs::write(Path::new(workspace).join(&name), &png).is_ok() { + saved = name; + } + // 1280×800 : sous la taille maximale de `prepareImageForModel`, la + // capture quadrillée part telle quelle. + let b = grid::overlay_grid(&png); + let part = json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(&b)) }, + }); + let mut msg = format!( + "[ok] capture prise (tu la vois ci-dessous, {VIEW_W}x{VIEW_H} px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille." + ); + if !saved.is_empty() { + msg += &format!( + " Enregistrée sous « {saved} ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : ![capture]({saved}). Un simple lien [capture]({saved}) ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci." + ); + } + Outcome { + result: msg, + image: Some(part), + } + } + + /// `cuAutoShot` : la page capturée pour l'aperçu (JPEG en mémoire) ; + /// "" si impossible, jamais bloquant. + pub async fn auto_shot(&self) -> String { + let s = match self.get().await { + Ok(s) => s, + Err(e) => { + eprintln!("[cu] aperçu : navigateur indisponible : {e}"); + return String::new(); + } + }; + let raw = match s.screenshot().await { + Ok(r) => r, + Err(e) => { + eprintln!("[cu] aperçu : capture impossible : {e}"); + return String::new(); + } + }; + let Ok(img) = image::load_from_memory(&raw) else { + eprintln!("[cu] aperçu : image illisible ({} octets)", raw.len()); + return String::new(); + }; + let mut out = std::io::Cursor::new(Vec::new()); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90); + if img.to_rgb8().write_with_encoder(enc).is_err() { + return String::new(); + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let id = format!("{nanos:x}"); + let mut shots = lock(&self.shots); + shots.1.insert(id.clone(), out.into_inner()); + shots.0.push(id.clone()); + while shots.0.len() > SHOT_RING { + let old = shots.0.remove(0); + shots.1.remove(&old); + } + id + } + + /// `handleCUShot` : un aperçu encore dans l'anneau. + pub fn shot(&self, id: &str) -> Option> { + lock(&self.shots).1.get(id.trim()).cloned() + } +} + +/// `time.Now().Format("20060102-150405")`, heure locale. +fn local_stamp() -> String { + let sec = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default(); + let c = crate::tz::Location::local().civil(sec); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + c.year, c.month, c.day, c.hour, c.minute, c.second + ) +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/browser/tests.rs b/rust/loki-rs/src/browser/tests.rs new file mode 100644 index 0000000..850b30c --- /dev/null +++ b/rust/loki-rs/src/browser/tests.rs @@ -0,0 +1,103 @@ +use super::*; + +#[test] +fn outils_et_plafond() { + for n in [ + "browser_open", + "browser_snapshot", + "browser_find", + "browser_click", + "browser_click_xy", + "browser_type", + "browser_key", + "browser_scroll", + "browser_screenshot", + ] { + assert!(is_tool(n), "{n}"); + } + assert!(!is_tool("web_screenshot")); + assert!(!auto_shot_tool("browser_snapshot") && !auto_shot_tool("browser_screenshot")); + assert!(auto_shot_tool("browser_find")); + let s = "é".repeat(MAX_OUTPUT); + assert_eq!(cap_output(&s), s); + assert_eq!(cap_output(&format!("{s}x")), format!("{s}\n…[tronqué]")); +} + +#[test] +fn nombres_comme_le_go() { + assert_eq!(go_num(640.0).to_string(), "640"); + assert_eq!(go_num(412.5).to_string(), "412.5"); + assert_eq!(go_num(-3.0).to_string(), "-3"); +} + +#[test] +fn arguments_de_lancement() { + let a = launch_args(Path::new("/tmp/p"), false); + assert_eq!(a[0], "--headless=new"); + assert!(a.contains(&"--user-data-dir=/tmp/p".to_string())); + assert_eq!(a.last().unwrap(), "about:blank"); + let h = launch_args(Path::new("/tmp/p"), true); + assert_eq!(h.len(), a.len() - 1); + assert_eq!(h[0], "--remote-debugging-port=0"); +} + +/// La grille : lignes translucides tous les 100 px, étiquettes opaques, +/// rien d'autre de touché. +#[test] +fn grille() { + let mut img = image::RgbaImage::from_pixel(250, 150, image::Rgba([40, 80, 120, 255])); + grid::draw(&mut img); + let px = |x, y| img.get_pixel(x, y).0; + assert_eq!(px(0, 0), [40, 80, 120, 255]); + // 255×130/255 + 40×125/255 → 149 ; 0 + 80×(1−a) → 39 ; 200a + 120(1−a) → 160. + assert_eq!(px(100, 50), [149, 39, 160, 255]); + assert_eq!(px(200, 140), [149, 39, 160, 255]); + assert_eq!(px(50, 100), [149, 39, 160, 255]); + assert_eq!(px(101, 50), [40, 80, 120, 255]); + // « 100 » en haut de la ligne : fond sombre, chiffre jaune. + assert_eq!(px(100, 1), [0, 0, 0, 255]); + assert_eq!(px(104, 4), [255, 255, 0, 255]); + // Une image illisible passe telle quelle. + assert_eq!(grid::overlay_grid(b"pas une image"), b"pas une image"); +} + +#[test] +fn profils_temporaires() { + let tmp = tempfile::tempdir().unwrap(); + let p = mkdtemp(tmp.path()).unwrap(); + assert!( + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("loki-cu-") + ); + assert!(p.is_dir()); + // Récent : gardé ; un autre dossier : jamais touché. + std::fs::create_dir(tmp.path().join("autre")).unwrap(); + sweep_stale(tmp.path()); + assert!(p.is_dir() && tmp.path().join("autre").is_dir()); +} + +#[tokio::test] +async fn lancement_impossible() { + let tmp = tempfile::tempdir().unwrap(); + let b = Browser::with("", tmp.path().to_path_buf()); + let mut a = Map::new(); + a.insert("url".into(), json!("x.test")); + let o = b.exec("browser_open", &a, false, "").await.unwrap(); + // Sans binaire imposé ni trouvé, ou introuvable : l'erreur dit quoi faire. + assert!(o.result.starts_with("[erreur] "), "{}", o.result); + let b = Browser::with("/nulle/part/chrome", tmp.path().to_path_buf()); + let o = b + .exec("browser_snapshot", &Map::new(), false, "") + .await + .unwrap(); + assert_eq!( + o.result, + "[erreur] fork/exec /nulle/part/chrome: no such file or directory" + ); + // Le profil du lancement raté est retiré. + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0); + assert_eq!(b.exec("web_open", &a, false, "").await, None); + assert!(b.shot("rien").is_none()); +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index f76a266..5a84078 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1324,6 +1324,7 @@ impl ChatShared { memory: None, web: Default::default(), jobs: crate::tools::jobs::global(), + browser: crate::browser::global(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 22e6669..dd1c1f1 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -27,7 +27,7 @@ //! d'écriture du mode Code (état du Go) : `cache_hint` et `preflight` absents. use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -116,6 +116,11 @@ pub struct ImageCache { } impl ImageCache { + /// `LOKI_HOME` (le parent du dossier des images). + pub fn home(&self) -> PathBuf { + self.dir.parent().map(Path::to_path_buf).unwrap_or_default() + } + /// `dir` : `LOKI_HOME/chatimg`. pub fn new(dir: PathBuf) -> Self { ImageCache { @@ -351,6 +356,8 @@ pub struct ChatShared { pub web: Arc, /// Les jobs d'arrière-plan du mode Code (lot 43). pub jobs: Arc, + /// Le navigateur piloté (lot 44). + pub browser: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -709,6 +716,34 @@ impl TurnHost for GoTurnHost { ..Default::default() }; } + // Navigateur piloté (lot 44) : la session est ici ; aperçu de la page + // après une action qui la change (jamais après une erreur), capture + // relayée au modèle. + if crate::browser::is_tool(name) { + let cfg = self.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + let home = self.shared.images.home(); + let ws = tokio::task::spawn_blocking(move || { + let env = std::env::var(crate::paths::WORKSPACE_ENV).ok(); + crate::paths::agent_workspace(&home, env.as_deref()) + }) + .await + .unwrap_or_default(); + if let Some(o) = self.shared.browser.exec(name, args, vision, &ws).await { + let live_shot = + if !o.result.starts_with("[erreur]") && crate::browser::auto_shot_tool(name) { + self.shared.browser.auto_shot().await + } else { + String::new() + }; + return ToolOutcome { + relay: o.image.map(|img| see_image_message(label, img)), + result: o.result, + live_shot, + ..Default::default() + }; + } + } // Jobs d'arrière-plan du mode Code (lot 43), dans le dossier de la // discussion ; contexte indisponible : le Go s'en charge. if name == "bash_tail" { diff --git a/rust/loki-rs/src/chatapi.rs b/rust/loki-rs/src/chatapi.rs index 719b9a9..d65a37e 100644 --- a/rust/loki-rs/src/chatapi.rs +++ b/rust/loki-rs/src/chatapi.rs @@ -52,6 +52,7 @@ pub fn handles(path: &str) -> bool { | "/api/conversations/rename" | "/api/conversations/delete" | "/api/perf/summary" + | "/api/chat/cushot" ) } @@ -72,6 +73,7 @@ pub fn is_internal(path: &str) -> bool { | "/internal/facade/v1/perf/record" | "/internal/facade/v1/perf/snapshot" | "/internal/facade/v1/bgjobs" + | "/internal/facade/v1/browser" ) } @@ -137,6 +139,49 @@ pub async fn serve_internal(chat: &Arc, req: Request) -> }, // L'anneau, de la plus ancienne à la plus récente (poids de // l'optimiseur). + // Navigateur piloté (lot 44) : les appels du Go (sous-agents, tâches + // IA) passent par la session tenue ici — un seul navigateur. + "/internal/facade/v1/browser" => { + let v = read_json(req.into_body()).await.unwrap_or_default(); + let b = &chat.shared.browser; + match str_of(&v, "op") { + "tool" => { + let cfg = chat.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + let home = chat.shared.images.home(); + let ws = tokio::task::spawn_blocking(move || { + let env = std::env::var(crate::paths::WORKSPACE_ENV).ok(); + crate::paths::agent_workspace(&home, env.as_deref()) + }) + .await + .unwrap_or_default(); + let args = v + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + match b.exec(str_of(&v, "name"), &args, vision, &ws).await { + Some(o) => send_json( + StatusCode::OK, + &json!({ "result": o.result, "image": o.image }), + ), + None => send_json( + StatusCode::BAD_REQUEST, + &json!({ "error": "outil du navigateur inconnu" }), + ), + } + } + "shot" => send_json(StatusCode::OK, &json!({ "id": b.auto_shot().await })), + "shutdown" => { + b.shutdown().await; + send_json(StatusCode::OK, &json!({})) + } + _ => send_json( + StatusCode::BAD_REQUEST, + &json!({ "error": "opération inconnue" }), + ), + } + } // Jobs d'arrière-plan encore en vie (lot 43) : l'optimiseur du Go // refuse de mesurer à côté. "/internal/facade/v1/bgjobs" => send_json( @@ -276,6 +321,22 @@ pub async fn serve( chat.stop().await; ok() } + // `handleCUShot` (lot 44) : un aperçu du navigateur piloté, en base64 + // (JSON, pour traverser aussi le tunnel du relais). + "/api/chat/cushot" => { + let id = query_param(&parts.uri, "id").unwrap_or_default(); + match chat.shared.browser.shot(&id) { + Some(b) => send_json( + StatusCode::OK, + &json!({ "ok": true, "mime": "image/jpeg", + "data": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b) }), + ), + None => send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": "capture expirée" }), + ), + } + } "/api/chat/reset" => { chat.reset().await; ok() diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 3faa175..9f55911 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -14,6 +14,7 @@ pub mod api; pub mod auth; pub mod backends; pub mod bench; +pub mod browser; pub mod chat; pub mod chatapi; pub mod chatx; diff --git a/rust/loki-rs/src/system.rs b/rust/loki-rs/src/system.rs index ba39da7..cc95a56 100644 --- a/rust/loki-rs/src/system.rs +++ b/rust/loki-rs/src/system.rs @@ -13,9 +13,10 @@ //! //! Les réglages restent des clés de la base. Ce qui vit dans le processus Go //! reste au Go : son cache de joignabilité (vidé par -//! `/internal/v1/web/reach/reset`), le Chromium des outils `browser_*` -//! (`/internal/v1/cdp/shutdown`) et le redémarrage du moteur quand la façade ne -//! le pilote pas (`/internal/v1/service/action`). +//! `/internal/v1/web/reach/reset`), le Chromium qu'il pilote quand la façade +//! ne tient pas la conversation (`/internal/v1/cdp/shutdown` ; sinon le +//! navigateur est celui de la façade, lot 44) et le redémarrage du moteur +//! quand la façade ne le pilote pas (`/internal/v1/service/action`). use std::time::{Duration, Instant}; @@ -528,8 +529,11 @@ pub async fn serve( if let Err(e) = put_bool(store, "computer", on).await { return fail(StatusCode::INTERNAL_SERVER_ERROR, e); } - // Couper l'interrupteur ferme le navigateur (au Go, qui le - // pilote pour les outils browser_*). + // Couper l'interrupteur ferme le navigateur : celui de la + // façade (lot 44), et celui du Go s'il en a ouvert un. + if !on { + crate::browser::global().shutdown().await; + } if !on && let Err(e) = store .internal_post("/internal/v1/cdp/shutdown", &json!({})) diff --git a/rust/loki-rs/tests/browser.rs b/rust/loki-rs/tests/browser.rs new file mode 100644 index 0000000..5aeea4d --- /dev/null +++ b/rust/loki-rs/tests/browser.rs @@ -0,0 +1,250 @@ +//! Lot 44 : navigateur piloté (outils `browser_*`) tenu par la façade, +//! rejoué sur le journal du Go (`internal/loki/parity_browser_test.go` → +//! `tests/parity_browser/browser.json`) contre le même faux Chrome : un +//! script qui annonce le port d'un faux serveur CDP, qui répond selon le +//! scénario de chaque étape et note chaque commande reçue. +#![cfg(target_os = "linux")] + +mod common; + +use std::sync::{Arc, Mutex}; + +use common::fakecdp::{self, Calls, Scene, image_facts}; +use loki_rs::browser::Browser; +use regex::Regex; +use serde_json::{Map, Value, json}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn navigateur_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_browser/browser.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let args_file = tmp.path().join("args"); + let ws = tmp.path().join("ws"); + let profiles = tmp.path().join("profils"); + std::fs::create_dir_all(&ws).unwrap(); + std::fs::create_dir_all(&profiles).unwrap(); + let scene: Scene = Arc::new(Mutex::new(json!({}))); + let calls: Calls = Default::default(); + let port = fakecdp::start(scene.clone(), calls.clone()).await; + let chrome = fakecdp::chrome_script(tmp.path(), port, &args_file); + let browser = Browser::with(&chrome.to_string_lossy(), profiles.clone()); + let cap = Regex::new(r"capture-[0-9]{8}-[0-9]{6}\.png").unwrap(); + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + if let Some(sc) = st.get("scene") { + *scene.lock().unwrap() = sc.clone(); + } + calls.lock().unwrap().clear(); + let args: Map = serde_json::from_value(st["args"].clone()).unwrap(); + let o = browser + .exec( + st["tool"].as_str().unwrap(), + &args, + st["vision"] == true, + &ws.to_string_lossy(), + ) + .await + .expect("un outil du navigateur"); + assert_eq!( + cap.replace_all(&o.result, "capture-{{T}}.png"), + st["result"].as_str().unwrap(), + "{name}" + ); + assert_eq!( + o.image.as_ref().map(image_facts), + st.get("image").cloned(), + "{name} : image" + ); + let mut saved: Vec = std::fs::read_dir(&ws) + .unwrap() + .flatten() + .map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + let _ = std::fs::remove_file(e.path()); + cap.replace_all(&n, "capture-{{T}}.png").into_owned() + }) + .collect(); + saved.sort(); + assert_eq!(json!(saved), st["saved"], "{name} : captures rangées"); + assert_eq!( + json!(*calls.lock().unwrap()), + st["calls"], + "{name} : commandes CDP" + ); + } + let launch: Vec = std::fs::read_to_string(&args_file) + .unwrap() + .trim_end_matches('\n') + .split('\n') + .map(|l| { + if l.starts_with("--user-data-dir=") { + "--user-data-dir={{PROFIL}}".into() + } else { + l.to_string() + } + }) + .collect(); + assert_eq!(json!(launch), fx["launch"], "lancement"); + + // Aperçu : une capture JPEG gardée dans l'anneau, servie par son id. + let id = browser.auto_shot().await; + assert!(!id.is_empty()); + let jpeg = browser.shot(&id).unwrap(); + assert_eq!(&jpeg[..2], &[0xff, 0xd8], "JPEG"); + assert_eq!( + image::load_from_memory(&jpeg) + .unwrap() + .to_rgb8() + .dimensions(), + (1280, 800) + ); + // Fermeture : le profil temporaire part avec le navigateur. + assert_eq!(std::fs::read_dir(&profiles).unwrap().count(), 1); + browser.shutdown().await; + assert_eq!(std::fs::read_dir(&profiles).unwrap().count(), 0); + assert!(!browser.running().await); +} + +/// Un vrai Chromium, de bout en bout (comme `TestCDPSmoke`) : page locale, +/// éléments numérotés, recherche, saisie, clic qui change la page, capture +/// quadrillée, aperçu. Seulement avec `LOKI_CU_SMOKE=1` et un navigateur. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn vrai_navigateur() { + if std::env::var("LOKI_CU_SMOKE").as_deref() != Ok("1") { + eprintln!("LOKI_CU_SMOKE=1 pour piloter un vrai navigateur"); + return; + } + let bin = loki_rs::system::chrome_path(); + if bin.is_empty() { + eprintln!("aucun navigateur"); + return; + } + let page = r#"Banc Loki +

Bonjour

+Voir la suite + + + +
Tout en bas +"#; + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut st, _)) = l.accept().await { + let mut buf = vec![0u8; 4096]; + let n = st.read(&mut buf).await.unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]); + let body = if req.starts_with("GET /suite") { + "SuiteRetour" + } else { + page + }; + let _ = st + .write_all( + format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes(), + ) + .await; + } + }); + let tmp = tempfile::tempdir().unwrap(); + let b = Browser::with(&bin, tmp.path().to_path_buf()); + let ws = tmp.path().to_string_lossy().into_owned(); + let run = |name: &'static str, args: Value| { + let b = &b; + let ws = ws.clone(); + async move { + let args: Map = serde_json::from_value(args).unwrap(); + b.exec(name, &args, true, &ws).await.unwrap() + } + }; + let open = run("browser_open", json!({ "url": format!("http://{addr}/") })).await; + assert!(open.result.starts_with("# Banc Loki\n"), "{}", open.result); + assert!( + open.result.contains(r#"link "Voir la suite""#), + "{}", + open.result + ); + assert!(open.result.contains("↓ encore ~"), "{}", open.result); + let find = run("browser_find", json!({ "text": "tout en bas" })).await; + assert!( + find.result.contains(r#"link "Tout en bas""#), + "{}", + find.result + ); + let snap = run("browser_snapshot", json!({})).await; + let re = Regex::new(r#"\[(\d+)\] textbox "Nom""#).unwrap(); + let typed = if let Some(c) = re.captures(&snap.result) { + run( + "browser_type", + json!({ "text": "Jean", "ref": c[1].parse::().unwrap() }), + ) + .await + } else { + let open = run("browser_open", json!({ "url": format!("http://{addr}/") })).await; + let c = re.captures(&open.result).expect("champ Nom"); + run( + "browser_type", + json!({ "text": "Jean", "ref": c[1].parse::().unwrap() }), + ) + .await + }; + assert!( + typed.result.contains(r#"textbox "Nom = Jean""#), + "{}", + typed.result + ); + let sel = Regex::new(r#"\[(\d+)\] combobox"#).unwrap(); + let c = sel.captures(&typed.result).expect("liste"); + let chosen = run( + "browser_type", + json!({ "text": "fra", "ref": c[1].parse::().unwrap() }), + ) + .await; + assert!( + chosen.result.starts_with("[ok] saisi « fra »"), + "{}", + chosen.result + ); + // Le lien est hors de la vue depuis le défilement de browser_find. + let found = run("browser_find", json!({ "text": "voir la suite" })).await; + let link = Regex::new(r#"\[(\d+)\] link "Voir la suite""#).unwrap(); + let c = link.captures(&found.result).expect("lien"); + let click = run( + "browser_click", + json!({ "ref": c[1].parse::().unwrap() }), + ) + .await; + assert!(click.result.contains("# Suite\n"), "{}", click.result); + let back = run("browser_open", json!({ "url": "back" })).await; + assert!(back.result.starts_with("# Banc Loki"), "{}", back.result); + let shot = run("browser_screenshot", json!({})).await; + assert!( + shot.result.starts_with("[ok] capture prise"), + "{}", + shot.result + ); + let facts = image_facts(shot.image.as_ref().unwrap()); + assert_eq!( + (facts["w"].clone(), facts["h"].clone()), + (json!(1280), json!(800)) + ); + assert!( + std::fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().starts_with("capture-")) + ); + let id = b.auto_shot().await; + assert!(b.shot(&id).is_some()); + let bad = run("browser_open", json!({ "url": "http://127.0.0.1:9/" })).await; + assert!( + bad.result + .starts_with("[erreur] navigation : chargement de http://127.0.0.1:9/ échoué"), + "{}", + bad.result + ); + b.shutdown().await; +} diff --git a/rust/loki-rs/tests/common/fakecdp.rs b/rust/loki-rs/tests/common/fakecdp.rs new file mode 100644 index 0000000..ba68c48 --- /dev/null +++ b/rust/loki-rs/tests/common/fakecdp.rs @@ -0,0 +1,222 @@ +//! Faux Chrome pour les tests du navigateur piloté (lot 44) : un script qui +//! annonce le port d'un faux serveur CDP dans DevToolsActivePort, et ce +//! serveur, qui répond selon un scénario et note chaque commande reçue +//! (comme `parity_browser_test.go`). +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use futures_util::{SinkExt as _, StreamExt as _}; +use serde_json::{Map, Value, json}; +use sha2::Digest as _; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +/// Le faux navigateur : il annonce `port`, range ses arguments dans +/// `args`, et attend. +pub fn chrome_script(dir: &Path, port: u16, args: &Path) -> PathBuf { + let p = dir.join("chrome"); + let script = format!( + r#"#!/bin/sh +for a in "$@"; do + case "$a" in + --user-data-dir=*) dir="${{a#--user-data-dir=}}" ;; + esac +done +printf '%s\n/devtools/browser/faux\n' '{port}' > "$dir/DevToolsActivePort" +printf '%s\n' "$@" > '{}' +exec sleep 600 +"#, + args.display() + ); + std::fs::write(&p, script).unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p +} + +/// `parityCDPAbbrev`. +pub fn abbrev(s: &str) -> String { + if s.len() <= 60 { + return s.to_string(); + } + let h = sha2::Sha256::digest(s.as_bytes()); + let head: String = s.chars().take(40).collect(); + format!("{head}…[{}:{}]", s.len(), hex::encode(&h[..6])) +} + +/// `parityCDPCall`. +pub fn call_line(method: &str, params: Option<&Value>) -> String { + let Some(Value::Object(p)) = params else { + return method.to_string(); + }; + let mut q = Map::new(); + for (k, v) in p { + let v = match v { + Value::String(s) if k == "expression" || k == "source" => json!(abbrev(s)), + v => v.clone(), + }; + q.insert(k.clone(), v); + } + format!("{method} {}", loki_rs::gojson::marshal(&Value::Object(q))) +} + +/// La capture unie du faux CDP (`parityPNG`). +pub fn png(w: u32, h: u32) -> String { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 80, 120, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + base64::engine::general_purpose::STANDARD.encode(out.into_inner()) +} + +pub type Scene = Arc>; +pub type Calls = Arc>>; + +/// La réponse du faux CDP à une commande (`reply` du journal Go). +pub fn reply(scene: &Value, method: &str, params: &Value) -> Result { + if let Some(msg) = scene["errors"].get(method).and_then(Value::as_str) { + return Err(msg.to_string()); + } + let val = |v: &Value| { + if v == "" { + json!({ "result": { "type": "undefined" } }) + } else { + json!({ "result": { "type": "object", "value": v } }) + } + }; + match method { + "Runtime.evaluate" => { + let expr = params["expression"].as_str().unwrap_or(""); + match expr { + "1" => return Ok(val(&json!(1))), + "document.readyState" => return Ok(val(&json!("complete"))), + "location.href" => return Ok(val(&json!(scene["href"].as_str().unwrap_or("")))), + _ => {} + } + if scene["throw"] == true { + return Ok(json!({ "result": { "type": "object" }, + "exceptionDetails": { "text": "Uncaught TypeError: x is not a function" } })); + } + let pick = |k: &str| scene.get(k).cloned().unwrap_or(Value::Null); + Ok(if expr.contains("window.__lokiRefs=map") { + val(&pick("snap")) + } else if expr.contains("var pool=ajQueryAll();") { + val(&pick("find")) + } else if expr.contains("inline:'center'") { + val(&pick("center")) + } else if expr.contains("return 'TEXT';") { + val(&pick("typed")) + } else if expr.starts_with("history.") || expr.starts_with("window.scrollBy(") { + val(&json!("ok")) + } else { + val(&json!("")) + }) + } + "Page.navigate" => { + let mut r = json!({ "frameId": "F1", "loaderId": "L1" }); + if let Some(e) = scene["nav_error"].as_str().filter(|e| !e.is_empty()) { + r["errorText"] = json!(e); + } + Ok(r) + } + "Page.captureScreenshot" => Ok(json!({ "data": png(1280, 800) })), + _ => Ok(json!({})), + } +} + +/// Le faux serveur CDP : `/json` puis la cible « page » en websocket. +pub async fn start(scene: Scene, calls: Calls) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut st, _)) = l.accept().await { + let (scene, calls) = (scene.clone(), calls.clone()); + tokio::spawn(async move { + let mut head = [0u8; 16]; + let n = st.peek(&mut head).await.unwrap_or(0); + if head[..n].starts_with(b"GET /json") { + let mut buf = vec![0u8; 4096]; + let _ = st.read(&mut buf).await; + let body = json!([ + { "type": "service_worker", "webSocketDebuggerUrl": format!("ws://127.0.0.1:{port}/devtools/sw/0") }, + { "type": "page", "webSocketDebuggerUrl": format!("ws://127.0.0.1:{port}/devtools/page/1") }, + ]) + .to_string(); + let _ = st + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await; + return; + } + let Ok(mut ws) = tokio_tungstenite::accept_async(st).await else { + return; + }; + while let Some(Ok(m)) = ws.next().await { + let Message::Text(t) = m else { continue }; + let v: Value = serde_json::from_str(t.as_str()).unwrap_or_default(); + let method = v["method"].as_str().unwrap_or("").to_string(); + calls + .lock() + .unwrap() + .push(call_line(&method, v.get("params"))); + let sc = scene.lock().unwrap().clone(); + let out = match reply(&sc, &method, &v["params"]) { + Ok(r) => json!({ "id": v["id"], "result": r }), + Err(e) => { + json!({ "id": v["id"], "error": { "code": -32000, "message": e } }) + } + }; + if ws + .send(Message::Text(out.to_string().into())) + .await + .is_err() + { + return; + } + } + }); + } + }); + port +} + +/// Dimensions et pixels témoins de l'image relayée (`parityImageFacts`). +pub fn image_facts(part: &Value) -> Value { + let u = part["image_url"]["url"].as_str().unwrap(); + let raw = base64::engine::general_purpose::STANDARD + .decode(u.strip_prefix("data:image/png;base64,").unwrap()) + .unwrap(); + let img = image::load_from_memory(&raw).unwrap().to_rgba8(); + let (w, h) = img.dimensions(); + let mut px = Map::new(); + for (x, y) in [ + (0, 0), + (100, 50), + (101, 50), + (50, 100), + (2, 2), + (104, 4), + (110, 12), + (300, 799), + (1279, 700), + (4, 204), + ] { + if x >= w || y >= h { + continue; + } + let p = img.get_pixel(x, y).0; + px.insert( + format!("{x},{y}"), + json!(format!("{},{},{},{}", p[0], p[1], p[2], p[3])), + ); + } + json!({ "w": w, "h": h, "px": px }) +} diff --git a/rust/loki-rs/tests/common/mod.rs b/rust/loki-rs/tests/common/mod.rs index 4060910..a8f4bd5 100644 --- a/rust/loki-rs/tests/common/mod.rs +++ b/rust/loki-rs/tests/common/mod.rs @@ -2,6 +2,7 @@ //! journalise les chemins demandés. #![allow(dead_code)] +pub mod fakecdp; pub mod fakego; pub mod parity; pub mod toolsfx; diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 353c4c3..0028010 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -673,6 +673,7 @@ async fn tour_complet_par_le_go() { memory: None, web: Default::default(), jobs: Default::default(), + browser: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2840,3 +2841,120 @@ async fn job_arriere_plan_par_la_facade() { } assert_eq!(jobs.running(), 0); } + +/// Lot 44 : le navigateur piloté est celui de la façade. Un tour qui ouvre +/// une page l'exécute chez elle (rien par le Go), l'aperçu est servi par +/// /api/chat/cushot, et le Go passe par /internal/facade/v1/browser. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn navigateur_par_la_facade() { + use common::fakecdp; + let t = tempfile::tempdir().unwrap(); + let scene: fakecdp::Scene = Arc::new(std::sync::Mutex::new(serde_json::json!({ + "snap": { "title": "Exemple", "url": "https://exemple.test/", "refs": ["[1] link \"Accueil\""] }, + "href": "https://exemple.test/", + }))); + let calls: fakecdp::Calls = Default::default(); + let port = fakecdp::start(scene, calls.clone()).await; + let chrome = fakecdp::chrome_script(t.path(), port, &t.path().join("args")); + std::fs::create_dir_all(t.path().join("profils")).unwrap(); + + // Le tour. + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"n1","type":"function","function":{"name":"browser_open","arguments":"{\"url\":\"exemple.test\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![call, answer("ouvert")]).await; + let browser = Arc::new(loki_rs::browser::Browser::with( + &chrome.to_string_lossy(), + t.path().join("profils"), + )); + let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.browser = browser.clone()).await; + svc.send(serde_json::json!({"message": "ouvre exemple", "cid": "n"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + let seen = seen.lock().unwrap().clone(); + let tool = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .rfind(|m| m["role"] == "tool") + .unwrap()["content"] + .clone(); + assert_eq!( + tool, + serde_json::json!( + "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"" + ) + ); + assert!(browser.last_shot().is_some(), "aperçu après l'ouverture"); + assert!( + calls + .lock() + .unwrap() + .iter() + .any(|c| c == "Page.navigate {\"url\":\"https://exemple.test\"}") + ); + browser.shutdown().await; + + // L'API du Go et l'aperçu servi, par une façade complète. + let t2 = tempfile::tempdir().unwrap(); + let (facade, _go, _stop2, chat) = fakego::start_with( + t2.path().into(), + fakego::Opts { + chat: true, + ..Default::default() + }, + ) + .await; + let chat = chat.unwrap(); + chat.shared + .browser + .impose(&chrome.to_string_lossy(), t.path().join("profils")); + let tok = [(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)]; + let post = |body: serde_json::Value| { + let b = body.to_string(); + async move { + let (code, out) = + fakego::send(facade, "POST", "/internal/facade/v1/browser", &tok, &b).await; + ( + code, + serde_json::from_slice::(&out).unwrap_or_default(), + ) + } + }; + let (code, out) = + post(serde_json::json!({ "op": "tool", "name": "browser_snapshot", "args": {} })).await; + assert_eq!(code, 200, "{out}"); + assert!( + out["result"].as_str().unwrap().starts_with("# Exemple\n"), + "{out}" + ); + assert_eq!(out["image"], serde_json::Value::Null); + let (_, out) = post(serde_json::json!({ "op": "shot" })).await; + let id = out["id"].as_str().unwrap().to_string(); + assert!(!id.is_empty()); + let (code, out) = + fakego::send(facade, "GET", &format!("/api/chat/cushot?id={id}"), &[], "").await; + assert_eq!(code, 200); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + (v["ok"].clone(), v["mime"].clone()), + (serde_json::json!(true), serde_json::json!("image/jpeg")) + ); + let (code, out) = fakego::send(facade, "GET", "/api/chat/cushot?id=perdu", &[], "").await; + assert_eq!(code, 404); + assert_eq!( + serde_json::from_slice::(&out).unwrap(), + serde_json::json!({ "ok": false, "error": "capture expirée" }) + ); + let (code, _) = post(serde_json::json!({ "op": "outil", "name": "x" })).await; + assert_eq!(code, 400); + let (code, _) = post(serde_json::json!({ "op": "tool", "name": "browser_voler" })).await; + assert_eq!(code, 400); + let (code, _) = post(serde_json::json!({ "op": "shutdown" })).await; + assert_eq!(code, 200); + assert!(!chat.shared.browser.running().await); +} diff --git a/rust/loki-rs/tests/parity_browser/browser.json b/rust/loki-rs/tests/parity_browser/browser.json new file mode 100644 index 0000000..a214d38 --- /dev/null +++ b/rust/loki-rs/tests/parity_browser/browser.json @@ -0,0 +1,1082 @@ +{ + "launch": [ + "--headless=new", + "--remote-debugging-port=0", + "--user-data-dir={{PROFIL}}", + "--no-first-run", + "--no-default-browser-check", + "--disable-gpu", + "--disable-extensions", + "--disable-background-networking", + "--disable-features=Translate,BackForwardCache", + "--no-sandbox", + "--disable-dev-shm-usage", + "--window-size=1280,900", + "about:blank" + ], + "steps": [ + { + "name": "ouverture sans url", + "tool": "browser_open", + "args": { + "url": " " + }, + "result": "[erreur] url manquante", + "saved": [], + "calls": [] + }, + { + "name": "ouverture", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_open", + "args": { + "url": "exemple.test" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Page.enable", + "Runtime.enable", + "DOM.enable", + "Network.enable", + "Emulation.setDeviceMetricsOverride {\"deviceScaleFactor\":1,\"height\":800,\"mobile\":false,\"width\":1280}", + "Page.addScriptToEvaluateOnNewDocument {\"source\":\"(function(){try{\\n window.open=function(…[264:24e8477d927f]\"}", + "Page.navigate {\"url\":\"https://exemple.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot inchangé", + "tool": "browser_snapshot", + "args": {}, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "réouverture : page complète", + "tool": "browser_open", + "args": { + "url": "https://exemple.test/" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://exemple.test/\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "page vide", + "scene": { + "snap": { + "above": 0, + "below": 0, + "refs": [], + "title": "", + "url": "about:blank" + }, + "href": "about:blank" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# \nabout:blank\n\n(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot null", + "scene": { + "href": "about:blank" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# \n\n\n(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot sans valeur", + "scene": { + "snap": "" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] snapshot illisible: unexpected end of JSON input", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot illisible", + "scene": { + "snap": "texte" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] snapshot illisible: json: cannot unmarshal string into Go value of type loki.cuSnap", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "exception JS", + "scene": { + "throw": true + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] JS: Uncaught TypeError: x is not a function", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilé", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_scroll", + "args": { + "direction": "down" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement haut", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_scroll", + "args": { + "direction": "UP" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,-600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement gauche puis droite", + "tool": "browser_scroll", + "args": { + "direction": "left" + }, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(-600,0);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement par défaut", + "tool": "browser_scroll", + "args": {}, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "direction inconnue", + "tool": "browser_scroll", + "args": { + "direction": "diagonale" + }, + "result": "[erreur] direction inconnue « diagonale » (up, down, left, right)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}" + ] + }, + { + "name": "clic", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "center": { + "x": 640, + "y": 412.5 + } + }, + "tool": "browser_click", + "args": { + "ref": 2 + }, + "result": "[ok] cliqué [2]\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:d543b8f1c16e]\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":640,\"y\":412.5}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mouseReleased\",\"x\":640,\"y\":412.5}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "clic introuvable", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_click", + "args": { + "ref": 9 + }, + "result": "[erreur] élément [9] introuvable — refais un browser_snapshot (la page a peut-être changé)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:22298800a28a]\",\"returnByValue\":true}" + ] + }, + { + "name": "clic sans ref", + "tool": "browser_click", + "args": { + "ref": "2" + }, + "result": "[erreur] ref manquant (numéro de l'élément)", + "saved": [], + "calls": [] + }, + { + "name": "clic refusé", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "center": { + "x": 1, + "y": 2 + }, + "errors": { + "Input.dispatchMouseEvent": "Cannot dispatch" + } + }, + "tool": "browser_click", + "args": { + "ref": 1 + }, + "result": "[erreur] Input.dispatchMouseEvent: Cannot dispatch", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:d1528b7be679]\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":1,\"y\":2}" + ] + }, + { + "name": "clic en coordonnées", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_click_xy", + "args": { + "x": 100.4, + "y": 250.5 + }, + "result": "[ok] clic en (100, 250)\n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":100.4,\"y\":250.5}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mouseReleased\",\"x\":100.4,\"y\":250.5}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "coordonnées manquantes", + "tool": "browser_click_xy", + "args": { + "x": 10 + }, + "result": "[erreur] coordonnées x et y requises (en pixels du screenshot)", + "saved": [], + "calls": [] + }, + { + "name": "saisie libre", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_type", + "args": { + "text": "bonjour & co" + }, + "result": "[ok] saisi « bonjour & co »\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.insertText {\"text\":\"bonjour \\u003cmonde\\u003e \\u0026 co\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "saisie dans un champ", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "typed": "TEXT" + }, + "tool": "browser_type", + "args": { + "ref": 3, + "text": "Jean" + }, + "result": "[ok] saisi « Jean »\n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4147:bba886791418]\",\"returnByValue\":true}", + "Input.insertText {\"text\":\"Jean\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "liste déroulante", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "typed": "SELECT" + }, + "tool": "browser_type", + "args": { + "ref": 5, + "text": "France" + }, + "result": "[ok] saisi « France »\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4149:abf8fb89d6ea]\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "option absente", + "scene": { + "typed": "NOOPT" + }, + "tool": "browser_type", + "args": { + "ref": 5, + "text": "Mars" + }, + "result": "[erreur] aucune option de la liste [5] ne correspond à « Mars » — fais un browser_screenshot pour voir les choix", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4147:5fd2e2486627]\",\"returnByValue\":true}" + ] + }, + { + "name": "champ introuvable", + "scene": { + "typed": "NOTFOUND" + }, + "tool": "browser_type", + "args": { + "ref": 7, + "text": "x" + }, + "result": "[erreur] champ [7] introuvable — refais un browser_snapshot", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4144:04f4774b7d77]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_key", + "args": { + "key": " Enter " + }, + "result": "[ok] touche Enter \n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchKeyEvent {\"code\":\"Enter\",\"key\":\"Enter\",\"nativeVirtualKeyCode\":13,\"type\":\"rawKeyDown\",\"windowsVirtualKeyCode\":13}", + "Input.dispatchKeyEvent {\"code\":\"Enter\",\"key\":\"Enter\",\"nativeVirtualKeyCode\":13,\"type\":\"keyUp\",\"windowsVirtualKeyCode\":13}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche esc", + "tool": "browser_key", + "args": { + "key": "esc" + }, + "result": "[ok] touche esc\n\n(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchKeyEvent {\"code\":\"Escape\",\"key\":\"Escape\",\"nativeVirtualKeyCode\":27,\"type\":\"rawKeyDown\",\"windowsVirtualKeyCode\":27}", + "Input.dispatchKeyEvent {\"code\":\"Escape\",\"key\":\"Escape\",\"nativeVirtualKeyCode\":27,\"type\":\"keyUp\",\"windowsVirtualKeyCode\":27}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche inconnue", + "tool": "browser_key", + "args": { + "key": "F13" + }, + "result": "[erreur] touche inconnue « F13 » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}" + ] + }, + { + "name": "recherche vide", + "tool": "browser_find", + "args": { + "text": "" + }, + "result": "[erreur] texte de recherche manquant", + "saved": [], + "calls": [] + }, + { + "name": "recherche", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "find": { + "refs": [ + "[2] button \"Créer un compte\"", + "[7] link \"Créer\"" + ] + } + }, + "tool": "browser_find", + "args": { + "text": "créer " + }, + "result": "2 élément(s) contenant « créer » (le 1er est amené dans la vue) — clique par numéro :\n[2] button \"Créer un compte\"\n[7] link \"Créer\"", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4257:2e4488312149]\",\"returnByValue\":true}" + ] + }, + { + "name": "recherche sans résultat", + "scene": { + "find": { + "refs": [] + } + }, + "tool": "browser_find", + "args": { + "text": "zzz" + }, + "result": "Aucun élément contenant « zzz » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4240:c7d0f581011e]\",\"returnByValue\":true}" + ] + }, + { + "name": "navigation en échec", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://nulle-part.test/", + "nav_error": "net::ERR_NAME_NOT_RESOLVED" + }, + "tool": "browser_open", + "args": { + "url": "nulle-part.test" + }, + "result": "[erreur] navigation : chargement de https://nulle-part.test échoué (net::ERR_NAME_NOT_RESOLVED) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://nulle-part.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}" + ] + }, + { + "name": "page d'erreur", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "chrome-error://chromewebdata/" + }, + "tool": "browser_open", + "args": { + "url": "http://x.test" + }, + "result": "[erreur] navigation : chargement de http://x.test échoué (page injoignable) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"http://x.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}" + ] + }, + { + "name": "navigation interrompue bénigne", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://dl.test/f.zip", + "nav_error": "net::ERR_ABORTED" + }, + "tool": "browser_open", + "args": { + "url": "https://dl.test/f.zip" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://dl.test/f.zip\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "retour", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_open", + "args": { + "url": " Back " + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"history.back();'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "avance", + "tool": "browser_open", + "args": { + "url": "forward" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"history.forward();'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "commande refusée", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "errors": { + "Page.navigate": "Cannot navigate to invalid URL" + } + }, + "tool": "browser_open", + "args": { + "url": "https://exemple.test/" + }, + "result": "[erreur] navigation : Page.navigate: Cannot navigate to invalid URL", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://exemple.test/\"}" + ] + }, + { + "name": "capture sans vision", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_screenshot", + "args": {}, + "result": "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", + "saved": [], + "calls": [] + }, + { + "name": "capture", + "vision": true, + "tool": "browser_screenshot", + "args": {}, + "result": "[ok] capture prise (tu la vois ci-dessous, 1280x800 px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille. Enregistrée sous « capture-{{T}}.png ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : ![capture](capture-{{T}}.png). Un simple lien [capture](capture-{{T}}.png) ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci.", + "image": { + "h": 800, + "px": { + "0,0": "40,80,120,255", + "100,50": "149,39,160,255", + "101,50": "40,80,120,255", + "104,4": "255,255,0,255", + "110,12": "0,0,0,255", + "1279,700": "149,39,160,255", + "2,2": "40,80,120,255", + "300,799": "149,39,160,255", + "4,204": "0,0,0,255", + "50,100": "149,39,160,255" + }, + "w": 1280 + }, + "saved": [ + "capture-{{T}}.png" + ], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.captureScreenshot {\"format\":\"png\"}" + ] + }, + { + "name": "capture refusée", + "scene": { + "errors": { + "Page.captureScreenshot": "Unable to capture" + } + }, + "vision": true, + "tool": "browser_screenshot", + "args": {}, + "result": "[erreur] Page.captureScreenshot: Unable to capture", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.captureScreenshot {\"format\":\"png\"}" + ] + }, + { + "name": "sortie plafonnée", + "scene": { + "snap": { + "refs": [ + "[1] link \"Un lien assez long pour remplir la sortie, numéro 1\"", + "[2] link \"Un lien assez long pour remplir la sortie, numéro 2\"", + "[3] link \"Un lien assez long pour remplir la sortie, numéro 3\"", + "[4] link \"Un lien assez long pour remplir la sortie, numéro 4\"", + "[5] link \"Un lien assez long pour remplir la sortie, numéro 5\"", + "[6] link \"Un lien assez long pour remplir la sortie, numéro 6\"", + "[7] link \"Un lien assez long pour remplir la sortie, numéro 7\"", + "[8] link \"Un lien assez long pour remplir la sortie, numéro 8\"", + "[9] link \"Un lien assez long pour remplir la sortie, numéro 9\"", + "[10] link \"Un lien assez long pour remplir la sortie, numéro 10\"", + "[11] link \"Un lien assez long pour remplir la sortie, numéro 11\"", + "[12] link \"Un lien assez long pour remplir la sortie, numéro 12\"", + "[13] link \"Un lien assez long pour remplir la sortie, numéro 13\"", + "[14] link \"Un lien assez long pour remplir la sortie, numéro 14\"", + "[15] link \"Un lien assez long pour remplir la sortie, numéro 15\"", + "[16] link \"Un lien assez long pour remplir la sortie, numéro 16\"", + "[17] link \"Un lien assez long pour remplir la sortie, numéro 17\"", + "[18] link \"Un lien assez long pour remplir la sortie, numéro 18\"", + "[19] link \"Un lien assez long pour remplir la sortie, numéro 19\"", + "[20] link \"Un lien assez long pour remplir la sortie, numéro 20\"", + "[21] link \"Un lien assez long pour remplir la sortie, numéro 21\"", + "[22] link \"Un lien assez long pour remplir la sortie, numéro 22\"", + "[23] link \"Un lien assez long pour remplir la sortie, numéro 23\"", + "[24] link \"Un lien assez long pour remplir la sortie, numéro 24\"", + "[25] link \"Un lien assez long pour remplir la sortie, numéro 25\"", + "[26] link \"Un lien assez long pour remplir la sortie, numéro 26\"", + "[27] link \"Un lien assez long pour remplir la sortie, numéro 27\"", + "[28] link \"Un lien assez long pour remplir la sortie, numéro 28\"", + "[29] link \"Un lien assez long pour remplir la sortie, numéro 29\"", + "[30] link \"Un lien assez long pour remplir la sortie, numéro 30\"", + "[31] link \"Un lien assez long pour remplir la sortie, numéro 31\"", + "[32] link \"Un lien assez long pour remplir la sortie, numéro 32\"", + "[33] link \"Un lien assez long pour remplir la sortie, numéro 33\"", + "[34] link \"Un lien assez long pour remplir la sortie, numéro 34\"", + "[35] link \"Un lien assez long pour remplir la sortie, numéro 35\"", + "[36] link \"Un lien assez long pour remplir la sortie, numéro 36\"", + "[37] link \"Un lien assez long pour remplir la sortie, numéro 37\"", + "[38] link \"Un lien assez long pour remplir la sortie, numéro 38\"", + "[39] link \"Un lien assez long pour remplir la sortie, numéro 39\"", + "[40] link \"Un lien assez long pour remplir la sortie, numéro 40\"", + "[41] link \"Un lien assez long pour remplir la sortie, numéro 41\"", + "[42] link \"Un lien assez long pour remplir la sortie, numéro 42\"", + "[43] link \"Un lien assez long pour remplir la sortie, numéro 43\"", + "[44] link \"Un lien assez long pour remplir la sortie, numéro 44\"", + "[45] link \"Un lien assez long pour remplir la sortie, numéro 45\"", + "[46] link \"Un lien assez long pour remplir la sortie, numéro 46\"", + "[47] link \"Un lien assez long pour remplir la sortie, numéro 47\"", + "[48] link \"Un lien assez long pour remplir la sortie, numéro 48\"", + "[49] link \"Un lien assez long pour remplir la sortie, numéro 49\"", + "[50] link \"Un lien assez long pour remplir la sortie, numéro 50\"", + "[51] link \"Un lien assez long pour remplir la sortie, numéro 51\"", + "[52] link \"Un lien assez long pour remplir la sortie, numéro 52\"", + "[53] link \"Un lien assez long pour remplir la sortie, numéro 53\"", + "[54] link \"Un lien assez long pour remplir la sortie, numéro 54\"", + "[55] link \"Un lien assez long pour remplir la sortie, numéro 55\"", + "[56] link \"Un lien assez long pour remplir la sortie, numéro 56\"", + "[57] link \"Un lien assez long pour remplir la sortie, numéro 57\"", + "[58] link \"Un lien assez long pour remplir la sortie, numéro 58\"", + "[59] link \"Un lien assez long pour remplir la sortie, numéro 59\"", + "[60] link \"Un lien assez long pour remplir la sortie, numéro 60\"", + "[61] link \"Un lien assez long pour remplir la sortie, numéro 61\"", + "[62] link \"Un lien assez long pour remplir la sortie, numéro 62\"", + "[63] link \"Un lien assez long pour remplir la sortie, numéro 63\"", + "[64] link \"Un lien assez long pour remplir la sortie, numéro 64\"", + "[65] link \"Un lien assez long pour remplir la sortie, numéro 65\"", + "[66] link \"Un lien assez long pour remplir la sortie, numéro 66\"", + "[67] link \"Un lien assez long pour remplir la sortie, numéro 67\"", + "[68] link \"Un lien assez long pour remplir la sortie, numéro 68\"", + "[69] link \"Un lien assez long pour remplir la sortie, numéro 69\"", + "[70] link \"Un lien assez long pour remplir la sortie, numéro 70\"", + "[71] link \"Un lien assez long pour remplir la sortie, numéro 71\"", + "[72] link \"Un lien assez long pour remplir la sortie, numéro 72\"", + "[73] link \"Un lien assez long pour remplir la sortie, numéro 73\"", + "[74] link \"Un lien assez long pour remplir la sortie, numéro 74\"", + "[75] link \"Un lien assez long pour remplir la sortie, numéro 75\"", + "[76] link \"Un lien assez long pour remplir la sortie, numéro 76\"", + "[77] link \"Un lien assez long pour remplir la sortie, numéro 77\"", + "[78] link \"Un lien assez long pour remplir la sortie, numéro 78\"", + "[79] link \"Un lien assez long pour remplir la sortie, numéro 79\"", + "[80] link \"Un lien assez long pour remplir la sortie, numéro 80\"", + "[81] link \"Un lien assez long pour remplir la sortie, numéro 81\"", + "[82] link \"Un lien assez long pour remplir la sortie, numéro 82\"", + "[83] link \"Un lien assez long pour remplir la sortie, numéro 83\"", + "[84] link \"Un lien assez long pour remplir la sortie, numéro 84\"", + "[85] link \"Un lien assez long pour remplir la sortie, numéro 85\"", + "[86] link \"Un lien assez long pour remplir la sortie, numéro 86\"", + "[87] link \"Un lien assez long pour remplir la sortie, numéro 87\"", + "[88] link \"Un lien assez long pour remplir la sortie, numéro 88\"", + "[89] link \"Un lien assez long pour remplir la sortie, numéro 89\"", + "[90] link \"Un lien assez long pour remplir la sortie, numéro 90\"", + "[91] link \"Un lien assez long pour remplir la sortie, numéro 91\"", + "[92] link \"Un lien assez long pour remplir la sortie, numéro 92\"", + "[93] link \"Un lien assez long pour remplir la sortie, numéro 93\"", + "[94] link \"Un lien assez long pour remplir la sortie, numéro 94\"", + "[95] link \"Un lien assez long pour remplir la sortie, numéro 95\"", + "[96] link \"Un lien assez long pour remplir la sortie, numéro 96\"", + "[97] link \"Un lien assez long pour remplir la sortie, numéro 97\"", + "[98] link \"Un lien assez long pour remplir la sortie, numéro 98\"", + "[99] link \"Un lien assez long pour remplir la sortie, numéro 99\"", + "[100] link \"Un lien assez long pour remplir la sortie, numéro 100\"", + "[101] link \"Un lien assez long pour remplir la sortie, numéro 101\"", + "[102] link \"Un lien assez long pour remplir la sortie, numéro 102\"", + "[103] link \"Un lien assez long pour remplir la sortie, numéro 103\"", + "[104] link \"Un lien assez long pour remplir la sortie, numéro 104\"", + "[105] link \"Un lien assez long pour remplir la sortie, numéro 105\"", + "[106] link \"Un lien assez long pour remplir la sortie, numéro 106\"", + "[107] link \"Un lien assez long pour remplir la sortie, numéro 107\"", + "[108] link \"Un lien assez long pour remplir la sortie, numéro 108\"", + "[109] link \"Un lien assez long pour remplir la sortie, numéro 109\"", + "[110] link \"Un lien assez long pour remplir la sortie, numéro 110\"", + "[111] link \"Un lien assez long pour remplir la sortie, numéro 111\"", + "[112] link \"Un lien assez long pour remplir la sortie, numéro 112\"", + "[113] link \"Un lien assez long pour remplir la sortie, numéro 113\"", + "[114] link \"Un lien assez long pour remplir la sortie, numéro 114\"", + "[115] link \"Un lien assez long pour remplir la sortie, numéro 115\"", + "[116] link \"Un lien assez long pour remplir la sortie, numéro 116\"", + "[117] link \"Un lien assez long pour remplir la sortie, numéro 117\"", + "[118] link \"Un lien assez long pour remplir la sortie, numéro 118\"", + "[119] link \"Un lien assez long pour remplir la sortie, numéro 119\"", + "[120] link \"Un lien assez long pour remplir la sortie, numéro 120\"", + "[121] link \"Un lien assez long pour remplir la sortie, numéro 121\"", + "[122] link \"Un lien assez long pour remplir la sortie, numéro 122\"", + "[123] link \"Un lien assez long pour remplir la sortie, numéro 123\"", + "[124] link \"Un lien assez long pour remplir la sortie, numéro 124\"", + "[125] link \"Un lien assez long pour remplir la sortie, numéro 125\"", + "[126] link \"Un lien assez long pour remplir la sortie, numéro 126\"", + "[127] link \"Un lien assez long pour remplir la sortie, numéro 127\"", + "[128] link \"Un lien assez long pour remplir la sortie, numéro 128\"", + "[129] link \"Un lien assez long pour remplir la sortie, numéro 129\"", + "[130] link \"Un lien assez long pour remplir la sortie, numéro 130\"", + "[131] link \"Un lien assez long pour remplir la sortie, numéro 131\"", + "[132] link \"Un lien assez long pour remplir la sortie, numéro 132\"", + "[133] link \"Un lien assez long pour remplir la sortie, numéro 133\"", + "[134] link \"Un lien assez long pour remplir la sortie, numéro 134\"", + "[135] link \"Un lien assez long pour remplir la sortie, numéro 135\"", + "[136] link \"Un lien assez long pour remplir la sortie, numéro 136\"", + "[137] link \"Un lien assez long pour remplir la sortie, numéro 137\"", + "[138] link \"Un lien assez long pour remplir la sortie, numéro 138\"", + "[139] link \"Un lien assez long pour remplir la sortie, numéro 139\"", + "[140] link \"Un lien assez long pour remplir la sortie, numéro 140\"", + "[141] link \"Un lien assez long pour remplir la sortie, numéro 141\"", + "[142] link \"Un lien assez long pour remplir la sortie, numéro 142\"", + "[143] link \"Un lien assez long pour remplir la sortie, numéro 143\"", + "[144] link \"Un lien assez long pour remplir la sortie, numéro 144\"", + "[145] link \"Un lien assez long pour remplir la sortie, numéro 145\"", + "[146] link \"Un lien assez long pour remplir la sortie, numéro 146\"", + "[147] link \"Un lien assez long pour remplir la sortie, numéro 147\"", + "[148] link \"Un lien assez long pour remplir la sortie, numéro 148\"", + "[149] link \"Un lien assez long pour remplir la sortie, numéro 149\"", + "[150] link \"Un lien assez long pour remplir la sortie, numéro 150\"", + "[151] link \"Un lien assez long pour remplir la sortie, numéro 151\"", + "[152] link \"Un lien assez long pour remplir la sortie, numéro 152\"", + "[153] link \"Un lien assez long pour remplir la sortie, numéro 153\"", + "[154] link \"Un lien assez long pour remplir la sortie, numéro 154\"", + "[155] link \"Un lien assez long pour remplir la sortie, numéro 155\"", + "[156] link \"Un lien assez long pour remplir la sortie, numéro 156\"", + "[157] link \"Un lien assez long pour remplir la sortie, numéro 157\"", + "[158] link \"Un lien assez long pour remplir la sortie, numéro 158\"", + "[159] link \"Un lien assez long pour remplir la sortie, numéro 159\"", + "[160] link \"Un lien assez long pour remplir la sortie, numéro 160\"", + "[161] link \"Un lien assez long pour remplir la sortie, numéro 161\"", + "[162] link \"Un lien assez long pour remplir la sortie, numéro 162\"", + "[163] link \"Un lien assez long pour remplir la sortie, numéro 163\"", + "[164] link \"Un lien assez long pour remplir la sortie, numéro 164\"", + "[165] link \"Un lien assez long pour remplir la sortie, numéro 165\"", + "[166] link \"Un lien assez long pour remplir la sortie, numéro 166\"", + "[167] link \"Un lien assez long pour remplir la sortie, numéro 167\"", + "[168] link \"Un lien assez long pour remplir la sortie, numéro 168\"", + "[169] link \"Un lien assez long pour remplir la sortie, numéro 169\"", + "[170] link \"Un lien assez long pour remplir la sortie, numéro 170\"", + "[171] link \"Un lien assez long pour remplir la sortie, numéro 171\"", + "[172] link \"Un lien assez long pour remplir la sortie, numéro 172\"", + "[173] link \"Un lien assez long pour remplir la sortie, numéro 173\"", + "[174] link \"Un lien assez long pour remplir la sortie, numéro 174\"", + "[175] link \"Un lien assez long pour remplir la sortie, numéro 175\"", + "[176] link \"Un lien assez long pour remplir la sortie, numéro 176\"", + "[177] link \"Un lien assez long pour remplir la sortie, numéro 177\"", + "[178] link \"Un lien assez long pour remplir la sortie, numéro 178\"", + "[179] link \"Un lien assez long pour remplir la sortie, numéro 179\"", + "[180] link \"Un lien assez long pour remplir la sortie, numéro 180\"", + "[181] link \"Un lien assez long pour remplir la sortie, numéro 181\"", + "[182] link \"Un lien assez long pour remplir la sortie, numéro 182\"", + "[183] link \"Un lien assez long pour remplir la sortie, numéro 183\"", + "[184] link \"Un lien assez long pour remplir la sortie, numéro 184\"", + "[185] link \"Un lien assez long pour remplir la sortie, numéro 185\"", + "[186] link \"Un lien assez long pour remplir la sortie, numéro 186\"", + "[187] link \"Un lien assez long pour remplir la sortie, numéro 187\"", + "[188] link \"Un lien assez long pour remplir la sortie, numéro 188\"", + "[189] link \"Un lien assez long pour remplir la sortie, numéro 189\"", + "[190] link \"Un lien assez long pour remplir la sortie, numéro 190\"", + "[191] link \"Un lien assez long pour remplir la sortie, numéro 191\"", + "[192] link \"Un lien assez long pour remplir la sortie, numéro 192\"", + "[193] link \"Un lien assez long pour remplir la sortie, numéro 193\"", + "[194] link \"Un lien assez long pour remplir la sortie, numéro 194\"", + "[195] link \"Un lien assez long pour remplir la sortie, numéro 195\"", + "[196] link \"Un lien assez long pour remplir la sortie, numéro 196\"", + "[197] link \"Un lien assez long pour remplir la sortie, numéro 197\"", + "[198] link \"Un lien assez long pour remplir la sortie, numéro 198\"", + "[199] link \"Un lien assez long pour remplir la sortie, numéro 199\"", + "[200] link \"Un lien assez long pour remplir la sortie, numéro 200\"" + ], + "title": "Long", + "url": "https://long.test/" + } + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# Long\nhttps://long.test/\n\n200 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Un lien assez long pour remplir la sortie, numéro 1\"\n[2] link \"Un lien assez long pour remplir la sortie, numéro 2\"\n[3] link \"Un lien assez long pour remplir la sortie, numéro 3\"\n[4] link \"Un lien assez long pour remplir la sortie, numéro 4\"\n[5] link \"Un lien assez long pour remplir la sortie, numéro 5\"\n[6] link \"Un lien assez long pour remplir la sortie, numéro 6\"\n[7] link \"Un lien assez long pour remplir la sortie, numéro 7\"\n[8] link \"Un lien assez long pour remplir la sortie, numéro 8\"\n[9] link \"Un lien assez long pour remplir la sortie, numéro 9\"\n[10] link \"Un lien assez long pour remplir la sortie, numéro 10\"\n[11] link \"Un lien assez long pour remplir la sortie, numéro 11\"\n[12] link \"Un lien assez long pour remplir la sortie, numéro 12\"\n[13] link \"Un lien assez long pour remplir la sortie, numéro 13\"\n[14] link \"Un lien assez long pour remplir la sortie, numéro 14\"\n[15] link \"Un lien assez long pour remplir la sortie, numéro 15\"\n[16] link \"Un lien assez long pour remplir la sortie, numéro 16\"\n[17] link \"Un lien assez long pour remplir la sortie, numéro 17\"\n[18] link \"Un lien assez long pour remplir la sortie, numéro 18\"\n[19] link \"Un lien assez long pour remplir la sortie, numéro 19\"\n[20] link \"Un lien assez long pour remplir la sortie, numéro 20\"\n[21] link \"Un lien assez long pour remplir la sortie, numéro 21\"\n[22] link \"Un lien assez long pour remplir la sortie, numéro 22\"\n[23] link \"Un lien assez long pour remplir la sortie, numéro 23\"\n[24] link \"Un lien assez long pour remplir la sortie, numéro 24\"\n[25] link \"Un lien assez long pour remplir la sortie, numéro 25\"\n[26] link \"Un lien assez long pour remplir la sortie, numéro 26\"\n[27] link \"Un lien assez long pour remplir la sortie, numéro 27\"\n[28] link \"Un lien assez long pour remplir la sortie, numéro 28\"\n[29] link \"Un lien assez long pour remplir la sortie, numéro 29\"\n[30] link \"Un lien assez long pour remplir la sortie, numéro 30\"\n[31] link \"Un lien assez long pour remplir la sortie, numéro 31\"\n[32] link \"Un lien assez long pour remplir la sortie, numéro 32\"\n[33] link \"Un lien assez long pour remplir la sortie, numéro 33\"\n[34] link \"Un lien assez long pour remplir la sortie, numéro 34\"\n[35] link \"Un lien assez long pour remplir la sortie, numéro 35\"\n[36] link \"Un lien assez long pour remplir la sortie, numéro 36\"\n[37] link \"Un lien assez long pour remplir la sortie, numéro 37\"\n[38] link \"Un lien assez long pour remplir la sortie, numéro 38\"\n[39] link \"Un lien assez long pour remplir la sortie, numéro 39\"\n[40] link \"Un lien assez long pour remplir la sortie, numéro 40\"\n[41] link \"Un lien assez long pour remplir la sortie, numéro 41\"\n[42] link \"Un lien assez long pour remplir la sortie, numéro 42\"\n[43] link \"Un lien assez long pour remplir la sortie, numéro 43\"\n[44] link \"Un lien assez long pour remplir la sortie, numéro 44\"\n[45] link \"Un lien assez long pour remplir la sortie, numéro 45\"\n[46] link \"Un lien assez long pour remplir la sortie, numéro 46\"\n[47] link \"Un lien assez long pour remplir la sortie, numéro 47\"\n[48] link \"Un lien assez long pour remplir la sortie, numéro 48\"\n[49] link \"Un lien assez long pour remplir la sortie, numéro 49\"\n[50] link \"Un lien assez long pour remplir la sortie, numéro 50\"\n[51] link \"Un lien assez long pour remplir la sortie, numéro 51\"\n[52] link \"Un lien assez long pour remplir la sortie, numéro 52\"\n[53] link \"Un lien assez long pour remplir la sortie, numéro 53\"\n[54] link \"Un lien assez long pour remplir la sortie, numéro 54\"\n[55] link \"Un lien assez long pour remplir la sortie, numéro 55\"\n[56] link \"Un lien assez long pour remplir la sortie, numéro 56\"\n[57] link \"Un lien assez long pour remplir la sortie, numéro 57\"\n[58] link \"Un lien assez long pour remplir la sortie, numéro 58\"\n[59] link \"Un lien assez long pour remplir la sortie, numéro 59\"\n[60] link \"Un lien assez long pour remplir la sortie, numéro 60\"\n[61] link \"Un lien assez long pour remplir la sortie, numéro 61\"\n[62] link \"Un lien assez long pour remplir la sortie, numéro 62\"\n[63] link \"Un lien assez long pour remplir la sortie, numéro 63\"\n[64] link \"Un lien assez long pour remplir la sortie, numéro 64\"\n[65] link \"Un lien assez long pour remplir la sortie, numéro 65\"\n[66] link \"Un lien assez long pour remplir la sortie, numéro 66\"\n[67] link \"Un lien assez long pour remplir la sortie, numéro 67\"\n[68] link \"Un lien assez long pour remplir la sortie, numéro 68\"\n[69] link \"Un lien assez long pour remplir la sortie, numéro 69\"\n[70] link \"Un lien assez long pour remplir la sortie, numéro 70\"\n[71] link \"Un lien assez long pour remplir la sortie, numéro 71\"\n[72] link \"Un lien assez long pour remplir la sortie, numéro 72\"\n[73] link \"Un lien assez long pour remplir la sortie, numéro 73\"\n[74] link \"Un lien assez long pour remplir la sortie, numéro 74\"\n[75] link \"Un lien assez long pour remplir la sortie, numéro 75\"\n[76] link \"Un lien assez long pour remplir la sortie, numéro 76\"\n[77] link \"Un lien assez long pour remplir la sortie, numéro 77\"\n[78] link \"Un lien assez long pour remplir la sortie, numéro 78\"\n[79] link \"Un lien assez long pour remplir la sortie, numéro 79\"\n[80] link \"Un lien assez long pour remplir la sortie, numéro 80\"\n[81] link \"Un lien assez long pour remplir la sortie, numéro 81\"\n[82] link \"Un lien assez long pour remplir la sortie, numéro 82\"\n[83] link \"Un lien assez long pour remplir la sortie, numéro 83\"\n[84] link \"Un lien assez long pour remplir la sortie, numéro 84\"\n[85] link \"Un lien assez long pour remplir la sortie, numéro 85\"\n[86] link \"Un lien assez long pour remplir la sortie, numéro 86\"\n[87] link \"Un lien assez long pour remplir la sortie, numéro 87\"\n[88] link \"Un lien assez long pour remplir la sortie, numéro 88\"\n[89] link \"Un lien assez long pour remplir la sortie, numéro 89\"\n[90] link \"Un lien assez long pour remplir la sortie, numéro 90\"\n[91] link \"Un lien assez long pour remplir la sortie, numéro 91\"\n[92] link \"Un lien assez long pour remplir la sortie, numéro 92\"\n[93] link \"Un lien assez long pour remplir la sortie, numéro 93\"\n[94] link \"Un lien assez long pour remplir la sortie, numéro 94\"\n[95] link \"Un lien assez long pour remplir la sortie, numéro 95\"\n[96] link \"Un lien assez long pour remplir la sortie, numéro 96\"\n[97] link \"Un lien assez long pour remplir la sortie, numéro 97\"\n[98] link \"Un lien assez long pour remplir la sortie, numéro 98\"\n[99] link \"Un lien assez long pour remplir la sortie, numéro 99\"\n[100] link \"Un lien assez long pour remplir la sortie, numéro 100\"\n[101] link \"Un lien assez long pour remplir la sortie, numéro 101\"\n[102] link \"Un lien assez long pour remplir la sortie, numéro 102\"\n[103] link \"Un lien assez long pour remplir la sortie, numéro 103\"\n[104] link \"Un lien assez long pour remplir la sortie, numéro 104\"\n[105] link \"Un lien assez long pour remplir la sortie, numéro 105\"\n[106] link \"Un lien assez long pour remplir la sortie, numéro 106\"\n[107] link \"Un lien assez long pour remplir la sortie, numéro 107\"\n[108] link \"Un lien assez long pour remplir la sortie, numéro 108\"\n[109] link \"Un lien assez long pour remplir la sortie, numéro 109\"\n[110] link \"Un lien assez long pour remplir la sortie, numéro 110\"\n[111] link \"Un lien assez long pour remplir la sortie, numéro 111\"\n[112] link \"Un lien assez long pour remplir la sortie, numéro 112\"\n[113] link \"Un lien assez long pour remplir la sortie, numéro 113\"\n[114] link \"Un lien assez long pour remplir la sortie, numéro 114\"\n[115] link \"Un lien assez long pour remplir la sortie, numéro 115\"\n[116] link \"Un lien assez long pour remplir la sortie, numéro 116\"\n[117] link \"Un lien assez long pour remplir la sortie, numéro 117\"\n[118] link \"Un lien assez long pour remplir la sortie, numéro 118\"\n[119] link \"Un lien assez long pour remplir la sortie, numéro 119\"\n[120] link \"Un lien assez long pour remplir la sortie, numéro 120\"\n[121] link \"Un lien assez long pour remplir la sortie, numéro 1\n…[tronqué]", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + } + ] +}