Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/e2e/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,16 @@ impl Browser {
Ok(self.watched.get(&page.session).cloned().unwrap_or_default())
}

/// What a watched page has said, and no more kept from it: its `Network`
/// events stop (an open page's socket may stream a great deal, every
/// frame one), and the rest are no longer kept.
pub fn unwatch(&mut self, page: &Page) -> Result<Watched> {
let said = self.watched(page)?;
self.send("Network.disable", json!({}), Some(&page.session))?;
self.watched.remove(&page.session);
Ok(said)
}

/// The body of a response a watched page had (its request's id, from its
/// `Network` events), as text; while the page is open.
pub fn response_body(&mut self, page: &Page, request: &str) -> Result<String> {
Expand Down
195 changes: 185 additions & 10 deletions crates/e2e/src/lanes/agent_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,141 @@ fn ticket(api: &Api, owner: &Keys, id: &str, agent: &str) -> Result<String> {
Ok(r.body["url"].as_str().unwrap_or("").to_string())
}

/// `agent`'s screen's page through a new ticket, connected or not by `PAGE`.
pub(super) fn open_screen(chrome: &mut Browser, api: &Api, owner: &Keys, id: &str, agent: &str) -> Result<(Page, bool)> {
let page = chrome.open(&ticket(api, owner, id, agent)?)?;
/// `agent`'s screen's page through a new ticket, connected or not by
/// `PAGE`, and what it said as it loaded (`loading`), for a FAIL's detail:
/// watched until then (past it, its socket's every frame would be kept).
pub(super) fn open_screen(chrome: &mut Browser, api: &Api, owner: &Keys, id: &str, agent: &str) -> Result<(Page, bool, Value)> {
let page = chrome.open_watched(&ticket(api, owner, id, agent)?)?;
let connected = chrome.until(&page, CONNECTED, PAGE);
Ok((page, connected))
let said = loading(chrome, &page)?;
Ok((page, connected, said))
}

/// The requests a FAIL's detail lists at most (the screen's page makes
/// about 40: itself, noVNC's modules, its two sockets), and what of an
/// error's answer it keeps.
const REQUESTS_MAX: usize = 100;
const BODY_MAX: usize = 300;

/// What a watched page said as it loaded, its watching then stopped: each
/// request and its answer; each error's answer, whose body says who
/// refused it (the cell's port, or the computer's screen server), with the
/// edge's id for it (`cf-ray`, in the Worker's logs); its console, its
/// exceptions, and what the browser logged.
fn loading(chrome: &mut Browser, page: &Page) -> Result<Value> {
// read while the page is watched: no body is kept past it
let errors: Vec<Value> = requests(&chrome.watched(page)?.events)
.into_iter()
.filter(|r| r.status.is_some_and(|s| s >= 400))
.take(REQUESTS_MAX)
.map(|r| json!({ "request": r.said(), "ray": r.ray, "body": chrome.response_body(page, &r.id).unwrap_or_default().chars().take(BODY_MAX).collect::<String>() }))
.collect();
let w = chrome.unwatch(page)?;
let of = |method: &str, said: fn(&Value) -> String| -> Vec<String> { w.events.iter().filter(|e| e["method"] == method).map(|e| said(&e["params"]).chars().take(BODY_MAX).collect()).collect() };
let console = |p: &Value| {
let args = p["args"].as_array().into_iter().flatten().map(|a| a["value"].as_str().map(str::to_string).unwrap_or_else(|| a["description"].as_str().map_or_else(|| a["value"].to_string(), str::to_string)));
format!("{}: {}", p["type"].as_str().unwrap_or(""), args.collect::<Vec<_>>().join(" "))
};
let exception = |p: &Value| {
let d = &p["exceptionDetails"];
d["exception"]["description"].as_str().or(d["text"].as_str()).unwrap_or("").to_string()
};
let logged = |p: &Value| format!("{}: {} {}", p["entry"]["level"].as_str().unwrap_or(""), p["entry"]["text"].as_str().unwrap_or(""), p["entry"]["url"].as_str().unwrap_or(""));
Ok(json!({
"requests": requests(&w.events).iter().take(REQUESTS_MAX).map(Request::said).collect::<Vec<_>>(),
"errors": errors,
"console": of("Runtime.consoleAPICalled", console),
"exceptions": of("Runtime.exceptionThrown", exception),
"log": of("Log.entryAdded", logged),
"dropped": w.dropped,
}))
}

/// One request a page made, or one socket it opened, as its `Network`
/// events say it.
#[derive(Debug, Clone, PartialEq)]
struct Request {
id: String,
/// `GET /p/6080/novnc/core/rfb.js`, or `socket /p/6080/control?…`.
what: String,
status: Option<u16>,
/// Why it failed (`net::ERR_…`), as Chrome says it.
failed: Option<String>,
closed: bool,
/// The edge's id for its answer (`cf-ray`).
ray: Option<String>,
}

impl Request {
/// `GET /p/6080/x.js 200`, `… failed net::ERR_…`, `… no answer`.
fn said(&self) -> String {
let mut answer: Vec<String> = self.status.iter().map(u16::to_string).chain(self.failed.iter().map(|why| format!("failed {why}"))).collect();
if answer.is_empty() {
answer.push("no answer".into());
}
if self.closed {
answer.push("closed".into());
}
format!("{} {}", self.what, answer.join(", "))
}
}

/// A page's requests and sockets, in the order it made them, from its
/// `Network` events. A redirect is one request id sent again: each hop is
/// a request of its own, answered by the redirect's status.
fn requests(events: &[Value]) -> Vec<Request> {
// its URL past its origin; a ticket's query (its one-time secret) not said
fn path(url: &str) -> String {
let past = url.split_once("://").map_or(url, |(_, rest)| rest);
let path = past.find('/').map_or("/", |i| &past[i..]);
let path = match path.split_once('?') {
Some((p, _)) if p.ends_with("/__ticket") => p,
_ => path,
};
path.chars().take(200).collect()
}
fn ray(response: &Value) -> Option<String> {
response["headers"].as_object()?.iter().find(|(k, _)| k.eq_ignore_ascii_case("cf-ray")).and_then(|(_, v)| v.as_str().map(str::to_string))
}
fn newest<'a>(all: &'a mut [Request], id: &str) -> Option<&'a mut Request> {
all.iter_mut().rev().find(|r| r.id == id)
}
let status = |response: &Value| response["status"].as_u64().and_then(|s| u16::try_from(s).ok());
let mut all: Vec<Request> = vec![];
// bounded by the events: each is read once
for e in events {
let p = &e["params"];
let id = p["requestId"].as_str().unwrap_or("");
let made = |what: String| Request { id: id.to_string(), what, status: None, failed: None, closed: false, ray: None };
match e["method"].as_str().unwrap_or("") {
"Network.requestWillBeSent" => {
if let (Some(hop), false) = (newest(&mut all, id), p["redirectResponse"].is_null()) {
hop.status = status(&p["redirectResponse"]);
hop.ray = ray(&p["redirectResponse"]);
}
all.push(made(format!("{} {}", p["request"]["method"].as_str().unwrap_or("GET"), path(p["request"]["url"].as_str().unwrap_or("")))));
}
"Network.webSocketCreated" => all.push(made(format!("socket {}", path(p["url"].as_str().unwrap_or(""))))),
"Network.responseReceived" | "Network.webSocketHandshakeResponseReceived" => {
if let Some(r) = newest(&mut all, id) {
r.status = status(&p["response"]);
r.ray = ray(&p["response"]);
}
}
"Network.loadingFailed" | "Network.webSocketFrameError" => {
if let Some(r) = newest(&mut all, id) {
r.failed = p["errorText"].as_str().or(p["errorMessage"].as_str()).map(str::to_string);
}
}
"Network.webSocketClosed" => {
if let Some(r) = newest(&mut all, id) {
r.closed = true;
}
}
_ => {}
}
}
all
}

/// Hermes' profile for an agent fragment (the bridge's `wire::profile`, for
Expand Down Expand Up @@ -783,15 +913,15 @@ pub fn agent_smoke(s: &mut Suite, api: &Api) -> Result<()> {
/// opened again after it closed shows it.
#[allow(clippy::too_many_arguments)]
fn screen_checks(s: &mut Suite, c: &Chat, chrome: &mut Browser, api: &Api, keys: &Keys, id: &str, browsing: &str, shots: &Path) -> Result<String> {
let (open, connected) = open_screen(chrome, api, keys, id, &c.agent)?;
let (open, connected, loaded) = open_screen(chrome, api, keys, id, &c.agent)?;
let before = connected.then(|| within(FOLLOW, || Frame::read(chrome, &open))).flatten();
let shows = chrome.eval(&open, "[document.getElementById('screen')?.dataset.agent, document.title]").unwrap_or_default();
// a tab of its own: no page of the platform's tells it the agent's name,
// so it names none (never its label)
s.ok(
"the screen's page (a ticket, in Chrome) shows its chat's agent's, connects to that agent's desktop and draws a frame",
connected && before.is_some() && shows[0] == c.agent.as_str() && shows[1] == "Screen",
json!({ "status": status(chrome, &open), "shows": shows, "frame": describe(&before) }),
json!({ "status": status(chrome, &open), "shows": shows, "frame": describe(&before), "loading": loaded }),
);
let _ = chrome.screenshot(&open, &shots.join("1-desktop-before.png"));
let browsed = c.say("smoke-4", browsing)?;
Expand Down Expand Up @@ -825,27 +955,27 @@ fn screen_checks(s: &mut Suite, c: &Chat, chrome: &mut Browser, api: &Api, keys:
json!({ "status": status(chrome, &open), "before": describe(&before), "now": describe(&Frame::read(chrome, &open)) }),
);
// a page opened now: connected, drawn, not blank; and Take over
let (now, connected) = open_screen(chrome, api, keys, id, &c.agent)?;
let (now, connected, loaded) = open_screen(chrome, api, keys, id, &c.agent)?;
let drawn = connected.then(|| within(FOLLOW, || Frame::read(chrome, &now).filter(|f| !f.blank()))).flatten();
let _ = chrome.screenshot(&now, &shots.join("3-opened-now.png"));
s.ok(
"opened now, the screen's page connects (its websockify socket) and draws a frame that is not blank (pixels sampled)",
drawn.is_some(),
json!({ "status": status(chrome, &now), "frame": describe(&Frame::read(chrome, &now)) }),
json!({ "status": status(chrome, &now), "frame": describe(&Frame::read(chrome, &now)), "loading": loaded }),
);
let taken = connected && chrome.eval(&now, "(document.getElementById('control').click(), true)").is_ok() && chrome.until(&now, "document.body.dataset.state === 'mine'", Duration::from_secs(20));
s.ok("Take over gives the person the screen (its control socket grants control: the agent's lease)", taken, status(chrome, &now));
// every page closed, then the screen opened again
chrome.close(open)?;
chrome.close(now)?;
let (again, connected) = open_screen(chrome, api, keys, id, &c.agent)?;
let (again, connected, loaded) = open_screen(chrome, api, keys, id, &c.agent)?;
let same = |f: &Frame| new_frame(f) && drawn.as_ref().is_none_or(|d| f.unlike(d) <= SAME);
let shown = connected.then(|| within(FOLLOW, || Frame::read(chrome, &again).filter(same))).flatten();
let _ = chrome.screenshot(&again, &shots.join("4-opened-again.png"));
s.ok(
"closed and opened again, the screen shows the browser: connected, a light page over the desktop, the frame it showed before it closed",
shown.is_some(),
json!({ "status": status(chrome, &again), "before": describe(&drawn), "now": describe(&Frame::read(chrome, &again)), "desktop": describe(&before) }),
json!({ "status": status(chrome, &again), "before": describe(&drawn), "now": describe(&Frame::read(chrome, &again)), "desktop": describe(&before), "loading": loaded }),
);
chrome.close(again)?;
Ok(browsed)
Expand Down Expand Up @@ -932,6 +1062,51 @@ mod tests {
assert_eq!((previewed("a + 2 commands"), previewed("a + b"), previewed("x + 1 command y")), ("a", "a + b", "x + 1 command y"));
}

/// Goal: a screen's page that never ran says why: each request with
/// its answer (a status, a failure, or none yet), each socket, and the
/// edge's id for an answer; a ticket's secret never. Method: the
/// `Network` events Chrome sends, a ticket's redirect, a module
/// refused, one failed and one never answered among them.
#[test]
fn a_page_says_each_request_and_its_answer() {
let at = |path: &str| format!("https://c-1--preview.finite.build{path}");
let sent = |id: &str, path: &str| json!({ "method": "Network.requestWillBeSent", "params": { "requestId": id, "request": { "method": "GET", "url": at(path) } } });
let answered = |id: &str, status: u16, ray: &str| json!({ "method": "Network.responseReceived", "params": { "requestId": id, "response": { "status": status, "headers": { "CF-Ray": ray } } } });
let mut redirected = sent("1", "/p/6080/?agent=a--k3x9");
redirected["params"]["redirectResponse"] = json!({ "status": 303, "headers": { "cf-ray": "r1" } });
let events = [
sent("1", "/__ticket?t=one-time-secret&next=/p/6080/?agent=a--k3x9"),
redirected,
answered("1", 200, "r2"),
sent("2", "/p/6080/novnc/core/rfb.js"),
answered("2", 502, "r3"),
sent("3", "/p/6080/novnc/core/util/logging.js"),
json!({ "method": "Network.loadingFailed", "params": { "requestId": "3", "errorText": "net::ERR_HTTP2_PROTOCOL_ERROR" } }),
sent("4", "/p/6080/novnc/core/websock.js"),
json!({ "method": "Network.webSocketCreated", "params": { "requestId": "5", "url": "wss://c-1--preview.finite.build/p/6080/control?viewer=v&agent=a--k3x9" } }),
json!({ "method": "Network.webSocketHandshakeResponseReceived", "params": { "requestId": "5", "response": { "status": 101, "headers": {} } } }),
json!({ "method": "Network.webSocketClosed", "params": { "requestId": "5" } }),
json!({ "method": "Runtime.consoleAPICalled", "params": { "type": "log", "args": [] } }),
];
let all = requests(&events);
let said: Vec<String> = all.iter().map(Request::said).collect();
assert_eq!(
said,
[
"GET /__ticket 303",
"GET /p/6080/?agent=a--k3x9 200",
"GET /p/6080/novnc/core/rfb.js 502",
"GET /p/6080/novnc/core/util/logging.js failed net::ERR_HTTP2_PROTOCOL_ERROR",
"GET /p/6080/novnc/core/websock.js no answer",
"socket /p/6080/control?viewer=v&agent=a--k3x9 101, closed",
]
);
assert_eq!(all.iter().map(|r| r.ray.as_deref()).collect::<Vec<_>>(), [Some("r1"), Some("r2"), Some("r3"), None, None, None], "each hop its own edge id, its header named in any case");
assert!(!format!("{all:?}").contains("one-time-secret"), "a ticket's secret is never said");
// an event for a request it never saw sent is no request
assert!(requests(&[answered("9", 500, "r9")]).is_empty());
}

/// The page's sample parses; nothing drawn yet is no frame.
#[test]
fn a_frame_parses_from_the_page() {
Expand Down
4 changes: 2 additions & 2 deletions crates/e2e/src/lanes/bot_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,11 @@ pub fn bot_mode(s: &mut Suite, api: &Api) -> Result<()> {
// Maple's screen, where she opened the app, is checked; Juniper's,
// a desktop she never used (started for its first viewer), only shot
for (agent, file, checked) in [(&maple, "maple-screen.png", true), (&juniper, "juniper-screen.png", false)] {
let (page, connected) = open_screen(&mut b, api, &keys, &id, agent)?;
let (page, connected, loaded) = open_screen(&mut b, api, &keys, &id, agent)?;
std::thread::sleep(Duration::from_secs(5));
let _ = b.screenshot(&page, &evidence.join(file));
if checked {
s.ok(&format!("{agent}'s screen connects (its browser shows the app: the evidence's shot)"), connected, file);
s.ok(&format!("{agent}'s screen connects (its browser shows the app: the evidence's shot)"), connected, json!({ "shot": file, "loading": loaded }));
} else {
println!(" ({agent}'s screen connected: {connected})");
}
Expand Down
Loading