mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 12:46:56 +02:00
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:
24 files changed
+3910
-11
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user