mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 21:48:47 +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
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! journalise les chemins demandés.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod fakecdp;
|
||||
pub mod fakego;
|
||||
pub mod parity;
|
||||
pub mod toolsfx;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user