mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 14:07:19 +02:00
feat: faster MCP browsing
This commit is contained in:
1 parent
1e75f179e1
commit
aec4870588
6 files changed
+975
-54
No files matched your search
@@ -411,6 +411,9 @@ function startFixtureServer(geoIpFixture, geoIpAsnFixture) {
|
||||
<p id="path">${url.pathname}</p>
|
||||
<button id="fixture-button" onclick="this.dataset.clicked='yes'; this.textContent='Clicked'">Click fixture</button>
|
||||
<a id="fixture-link" href="/linked">Open the linked page</a>
|
||||
<input id="fixture-input" onkeydown="if (event.key === 'Enter') this.dataset.entered = 'yes'">
|
||||
<div id="fixture-spacer" style="height: 3000px"></div>
|
||||
<p id="fixture-end">End of the fixture</p>
|
||||
<script>window.__fixtureReady = true;</script>
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
@@ -715,6 +715,81 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
"/linked",
|
||||
);
|
||||
|
||||
// The MCP browser tools, through the engine an agent reaches over the
|
||||
// remote bridge. Each action says where the page is afterwards.
|
||||
let rpcId = 0;
|
||||
let mcpSession = null;
|
||||
const mcp = async (method, params) => {
|
||||
const response = await fetch(`${base}/v1/e2e/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${saved.api_token}`,
|
||||
"content-type": "application/json",
|
||||
...(mcpSession ? { "mcp-session-id": mcpSession } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
||||
});
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
const answer = await response.json();
|
||||
mcpSession = answer.session_id ?? mcpSession;
|
||||
assert.equal(answer.body.error, undefined, JSON.stringify(answer.body));
|
||||
return answer.body.result;
|
||||
};
|
||||
const tool = async (name, args = {}) => {
|
||||
const result = await mcp("tools/call", {
|
||||
name,
|
||||
arguments: { profile_id: profile.id, ...args },
|
||||
});
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return result.content[0];
|
||||
};
|
||||
const toolJson = async (name, args) =>
|
||||
JSON.parse((await tool(name, args)).text);
|
||||
await mcp("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
clientInfo: { name: "Donut E2E", version: "1" },
|
||||
});
|
||||
|
||||
const back = await tool("go_back");
|
||||
assert.match(back.text, /^Went back/);
|
||||
assert.match(back.text, /Page: Donut E2E Browser Fixture <.*\/wayfern>/);
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#path').textContent"),
|
||||
"/wayfern",
|
||||
);
|
||||
|
||||
const scrolled = await toolJson("scroll", { direction: "down" });
|
||||
assert.equal(scrolled.moved, true, JSON.stringify(scrolled));
|
||||
assert.ok(scrolled.scroll_y > 0);
|
||||
assert.equal(scrolled.at_top, false);
|
||||
|
||||
await cdp.evaluate("document.querySelector('#fixture-input').focus()");
|
||||
const pressed = await tool("press_key", { key: "Enter" });
|
||||
assert.match(pressed.text, /^Pressed Enter/);
|
||||
assert.equal(
|
||||
await cdp.evaluate(
|
||||
"document.querySelector('#fixture-input').dataset.entered",
|
||||
),
|
||||
"yes",
|
||||
);
|
||||
|
||||
const found = await toolJson("wait_for", { selector: "#fixture-end" });
|
||||
assert.equal(found.met, true);
|
||||
assert.equal(found.title, "Donut E2E Browser Fixture");
|
||||
const missing = await toolJson("wait_for", {
|
||||
text: "Text that is never on the fixture",
|
||||
timeout_ms: 300,
|
||||
});
|
||||
assert.equal(missing.met, false);
|
||||
|
||||
const shot = await tool("screenshot");
|
||||
assert.equal(shot.mimeType, "image/jpeg");
|
||||
const jpeg = Buffer.from(shot.data, "base64");
|
||||
assert.equal(jpeg.readUInt16BE(0), 0xffd8, "a JPEG by default");
|
||||
|
||||
const visited = await tool("navigate", { url: `${fixtureUrl}/again` });
|
||||
assert.match(visited.text, /Page: Donut E2E Browser Fixture <.*\/again>/);
|
||||
|
||||
const runningProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
|
||||
@@ -1045,6 +1045,8 @@ impl ApiServer {
|
||||
.port();
|
||||
|
||||
let v1_routes = build_v1_router();
|
||||
#[cfg(feature = "e2e")]
|
||||
let v1_routes = v1_routes.route("/v1/e2e/mcp", axum::routing::post(e2e_mcp_message));
|
||||
|
||||
let api = ApiDoc::openapi();
|
||||
|
||||
@@ -1101,6 +1103,29 @@ impl ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The suites' way into the MCP tool engine, which the app otherwise serves
|
||||
/// only over the remote bridge. Built into E2E builds only, never published.
|
||||
#[cfg(feature = "e2e")]
|
||||
async fn e2e_mcp_message(headers: HeaderMap, body: axum::body::Bytes) -> Response {
|
||||
use crate::mcp_server::{McpOutcome, McpServer};
|
||||
let session = headers
|
||||
.get("mcp-session-id")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
match McpServer::instance()
|
||||
.handle_message_with(session, &body, None)
|
||||
.await
|
||||
{
|
||||
McpOutcome::Body {
|
||||
body,
|
||||
new_session_id,
|
||||
} => Json(serde_json::json!({ "session_id": new_session_id, "body": body })).into_response(),
|
||||
McpOutcome::Accepted => StatusCode::ACCEPTED.into_response(),
|
||||
McpOutcome::UnknownSession => StatusCode::NOT_FOUND.into_response(),
|
||||
McpOutcome::BadRequest => StatusCode::BAD_REQUEST.into_response(),
|
||||
McpOutcome::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register every `/v1` handler.
|
||||
///
|
||||
/// Pulled out of `start` so a test can build it. Axum panics when two handlers
|
||||
|
||||
+828
-54
File diff suppressed because it is too large.
Load diff
@@ -37,6 +37,10 @@ pub const TOOLSETS: &[(&str, &[&str])] = &[
|
||||
"batch_run_profiles",
|
||||
"batch_stop_profiles",
|
||||
"navigate",
|
||||
"go_back",
|
||||
"scroll",
|
||||
"press_key",
|
||||
"wait_for",
|
||||
"screenshot",
|
||||
"evaluate_javascript",
|
||||
"click_element",
|
||||
|
||||
@@ -67,6 +67,9 @@ impl NavigationWatch {
|
||||
};
|
||||
if method == "Page.loadEventFired" {
|
||||
self.loaded = true;
|
||||
} else if method == "Page.frameNavigated" && restored_from_cache(params) {
|
||||
// A page the back-forward cache keeps comes back whole: no load event.
|
||||
self.loaded = true;
|
||||
} else if method == "Page.navigatedWithinDocument" && in_main_frame() {
|
||||
self.same_document = true;
|
||||
} else if STARTED.contains(&method) && in_main_frame() {
|
||||
@@ -98,6 +101,15 @@ impl NavigationWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `Page.frameNavigated` brought back the top frame from the
|
||||
/// back-forward cache.
|
||||
fn restored_from_cache(params: Option<&Value>) -> bool {
|
||||
params.is_some_and(|p| {
|
||||
p.get("type").and_then(Value::as_str) == Some("BackForwardCacheRestore")
|
||||
&& p.pointer("/frame/parentId").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// The top frame's id, from a `Page.getFrameTree` reply.
|
||||
pub fn main_frame_of(tree: &Value) -> Option<String> {
|
||||
tree
|
||||
@@ -130,6 +142,34 @@ mod tests {
|
||||
assert!(!watch.navigated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_back_from_the_cache_ends_the_wait_without_a_load_event() {
|
||||
let start = Instant::now();
|
||||
let mut watch = NavigationWatch::new(Some("main".to_string()), true);
|
||||
watch.on_reply(start);
|
||||
watch.on_event(
|
||||
"Page.frameNavigated",
|
||||
Some(
|
||||
&json!({ "frame": { "id": "ad", "parentId": "main" }, "type": "BackForwardCacheRestore" }),
|
||||
),
|
||||
);
|
||||
assert!(!watch.done(start), "a child frame is not the page");
|
||||
watch.on_event(
|
||||
"Page.frameNavigated",
|
||||
Some(&json!({ "frame": { "id": "main" }, "type": "Navigation" })),
|
||||
);
|
||||
assert!(
|
||||
!watch.done(start),
|
||||
"a normal navigation still waits for its load"
|
||||
);
|
||||
watch.on_event(
|
||||
"Page.frameNavigated",
|
||||
Some(&json!({ "frame": { "id": "main" }, "type": "BackForwardCacheRestore" })),
|
||||
);
|
||||
assert!(watch.done(start));
|
||||
assert!(watch.navigated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_started_navigation_waits_for_its_load() {
|
||||
let start = Instant::now();
|
||||
|
||||
Reference in new issue
Block a user