From fe51bdeee903d2fffaf580ace737976d46641f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:17:51 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2044=20:=20navigateur=20pilot=C3=A9=20tenu?= =?UTF-8?q?=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade tient la session Chrome des outils browser_* (src/browser/, port de computer_cdp.go, computer_use.go, browser_grid.go et cu_autoshot.go) : lancement (profil temporaire, mêmes arguments, tué avec la façade), client CDP multiplexé sur websocket, attente du chargement et du calme réseau, scripts injectés copiés à l'octet près, les neuf outils aux textes du Go (snapshot dédoublonné, recherche, clics, saisie et listes déroulantes, touches, défilement, navigation et ses erreurs), capture rangée propre et quadrillée pour le modèle (mêmes pixels), aperçus JPEG en anneau servis par /api/chat/cushot. Un seul navigateur : quand la façade tient la conversation, le Go lui passe les outils de ses sous-agents et tâches IA, ses aperçus et la fermeture (POST /internal/facade/v1/browser) ; façade injoignable, le navigateur du Go prend le relais. Couper l'interrupteur ferme les deux. Parité : journal de 42 étapes tiré du Go contre un faux Chrome (script + faux serveur CDP qui note chaque commande, scripts résumés par leur empreinte, pixels de la grille), rejoué par tests/browser.rs ; un vrai Chromium de bout en bout avec LOKI_CU_SMOKE=1 ; un tour de la façade qui ouvre une page (tests/lot6.rs) ; TestFacadeNavigateur côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...-rust-migration-lot44-navigateur-design.md | 138 ++ internal/loki/computer_cdp.go | 1 + internal/loki/computer_use.go | 30 + internal/loki/cu_autoshot.go | 3 + internal/loki/facade_browser.go | 86 ++ internal/loki/facade_browser_test.go | 53 + internal/loki/facade_chat.go | 2 + internal/loki/facade_chat_test.go | 8 +- internal/loki/parity_browser_test.go | 399 ++++++ rust/loki-rs/src/browser/grid.rs | 126 ++ rust/loki-rs/src/browser/js.rs | 69 + rust/loki-rs/src/browser/mod.rs | 1114 +++++++++++++++++ rust/loki-rs/src/browser/tests.rs | 103 ++ rust/loki-rs/src/chat/generate.rs | 1 + rust/loki-rs/src/chat/host.rs | 37 +- rust/loki-rs/src/chatapi.rs | 61 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/system.rs | 14 +- rust/loki-rs/tests/browser.rs | 250 ++++ rust/loki-rs/tests/common/fakecdp.rs | 222 ++++ rust/loki-rs/tests/common/mod.rs | 1 + rust/loki-rs/tests/lot6.rs | 118 ++ .../loki-rs/tests/parity_browser/browser.json | 1082 ++++++++++++++++ 24 files changed, 3910 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md create mode 100644 internal/loki/facade_browser.go create mode 100644 internal/loki/facade_browser_test.go create mode 100644 internal/loki/parity_browser_test.go create mode 100644 rust/loki-rs/src/browser/grid.rs create mode 100644 rust/loki-rs/src/browser/js.rs create mode 100644 rust/loki-rs/src/browser/mod.rs create mode 100644 rust/loki-rs/src/browser/tests.rs create mode 100644 rust/loki-rs/tests/browser.rs create mode 100644 rust/loki-rs/tests/common/fakecdp.rs create mode 100644 rust/loki-rs/tests/parity_browser/browser.json 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}" + ] + } + ] +}