Lot 44 : navigateur piloté tenu par la façade

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 06:17:51 +00:00
1 parent 7288db632e
commit fe51bdeee9
24 files changed
+3910 -11

No files matched your search

+1 -1
View File
@@ -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
@@ -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.
+1
View File
@@ -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 {
+30
View File
@@ -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
}
+3
View File
@@ -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)
+86
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
}
+2
View File
@@ -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
+4 -4
View File
@@ -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())
}
}
+399
View File
@@ -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 ; "<absent>" : 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 == "<absent>" {
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("<absent>"), ""
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: "<absent>"}, 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 <monde> & 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 <b>")})
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)
}
}
+126
View File
@@ -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<u8> {
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;
}
}
+69
View File
@@ -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;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
"#;
/// La fin de `cdpSnapshotJS` (préfixée de [`HELPERS_JS`]).
pub const SNAPSHOT_TAIL_JS: &str = r#"(function(){
ajClear();
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
var map={},refs=[],i=0;
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
el.setAttribute('data-loki-ref',String(i));
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
}
window.__lokiRefs=map;
var de=document.documentElement;
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
return {title:document.title||'',url:location.href,refs:refs,
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
})()"#;
/// `cdpFindJS` : recherche non destructive sur toute la page.
pub const FIND_JS: &str = r#"function(q){
q=(q||'').toLowerCase();
var pool=ajQueryAll();
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
var els=pool.filter(ajActionable);
var refs=[],count=0,first=null;
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
var ref=el.getAttribute('data-loki-ref');
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
if(!first)first=el;count++;
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
}
if(first)first.scrollIntoView({block:'center'});
return {refs:refs};
}"#;
/// `cdpSnapshotJS`.
pub fn snapshot_js() -> String {
format!("{HELPERS_JS}{SNAPSHOT_TAIL_JS}")
}
File diff suppressed because it is too large. Load diff
+103
View File
@@ -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());
}
+1
View File
@@ -1324,6 +1324,7 @@ impl ChatShared {
memory: None,
web: Default::default(),
jobs: crate::tools::jobs::global(),
browser: crate::browser::global(),
go_version: Default::default(),
}
}
+36 -1
View File
@@ -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<crate::webtools::Web>,
/// Les jobs d'arrière-plan du mode Code (lot 43).
pub jobs: Arc<crate::tools::jobs::BgJobs>,
/// Le navigateur piloté (lot 44).
pub browser: Arc<crate::browser::Browser>,
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
/// une fois.
pub go_version: tokio::sync::OnceCell<String>,
@@ -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" {
+61
View File
@@ -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<ChatService>, req: Request<Incoming>) ->
},
// 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()
+1
View File
@@ -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;
+9 -5
View File
@@ -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!({}))
+250
View File
@@ -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<String, Value> = 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<String> = 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<String> = 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#"<!doctype html><html><head><title>Banc Loki</title></head><body>
<h1>Bonjour</h1>
<a href="/suite">Voir la suite</a>
<label for="nom">Nom</label><input id="nom" value="">
<select id="pays"><option>Belgique</option><option>France</option></select>
<button onclick="document.title='Cliqué'">Valider</button>
<div style="height:3000px"></div><a href="/bas">Tout en bas</a>
</body></html>"#;
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") {
"<html><head><title>Suite</title></head><body><a href=\"/\">Retour</a></body></html>"
} 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<String, Value> = 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::<f64>().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::<f64>().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::<f64>().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::<f64>().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;
}
+222
View File
@@ -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<Mutex<Value>>;
pub type Calls = Arc<Mutex<Vec<String>>>;
/// La réponse du faux CDP à une commande (`reply` du journal Go).
pub fn reply(scene: &Value, method: &str, params: &Value) -> Result<Value, String> {
if let Some(msg) = scene["errors"].get(method).and_then(Value::as_str) {
return Err(msg.to_string());
}
let val = |v: &Value| {
if v == "<absent>" {
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!("<absent>"))
})
}
"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 })
}
+1
View File
@@ -2,6 +2,7 @@
//! journalise les chemins demandés.
#![allow(dead_code)]
pub mod fakecdp;
pub mod fakego;
pub mod parity;
pub mod toolsfx;
+118
View File
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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);
}
File diff suppressed because it is too large. Load diff