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
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)
}
}