feat(react): React introspection, Web Vitals, and SPA primitives (#1257)
* feat(react): first-class React introspection, Web Vitals, and nextjs skill
Add React-general and web-universal features as first-class agent-browser verbs
(react tree/inspect/renders/suspense, vitals, pushstate). Genuinely Next.js-specific
workflows (PPR cookie protocol, /_next/mcp bridge, dev-server endpoints) ship as
a new `nextjs` skill that composes the primitives. No new runtime dependencies -
the React DevTools installHook.js is vendored (MIT) and include_str!'d into the
binary.
New commands:
react tree Full React component tree (depth id parent name)
react inspect <fiberId> Props, hooks, state, source for one fiber
react renders start|stop Fiber profiler with Insts/Mounts/Re-renders/Self/DOM
+ prev->next change details
react suspense Suspense boundaries + classifier (client-hook,
request-api, server-fetch, cache, stream, framework)
+ root-cause grouping + recommendations
vitals [url] LCP/CLS/TTFB/FCP/INP + React hydration phases
pushstate <url> Generic SPA client-side navigation
removeinitscript <id> Remove a script registered via addinitscript
New launch flags:
--init-script <path> Register init scripts before first navigation
(repeatable; env AGENT_BROWSER_INIT_SCRIPTS)
--enable <feature> Built-in init scripts; currently react-devtools
(repeatable; env AGENT_BROWSER_ENABLE)
Other primitives:
network route ... --resource-type <csv> Filter by CDP resource type
cookies set --curl <file> Auto-detects JSON/cURL/Cookie-header
* fixes
* fixes
* fixes
This commit is contained in:
@@ -5109,3 +5109,238 @@ async fn e2e_explicit_state_load_restores_cookies() {
|
||||
|
||||
let _ = std::fs::remove_file(&state_path);
|
||||
}
|
||||
|
||||
// === React / Web Vitals primitives ===
|
||||
|
||||
const REACT_FIXTURE_HTML: &str = r#"<!doctype html>
|
||||
<html>
|
||||
<head><title>React fixture</title></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script>
|
||||
const { useState, createElement: h } = React;
|
||||
function Counter({ label }) {
|
||||
const [n, setN] = useState(0);
|
||||
return h("button", { onClick: () => setN(n + 1) }, label + ": " + n);
|
||||
}
|
||||
function App() {
|
||||
return h("div", {}, [
|
||||
h("h1", { key: "t" }, "Hello"),
|
||||
h(Counter, { key: "c1", label: "A" }),
|
||||
h(Counter, { key: "c2", label: "B" }),
|
||||
]);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(h(App));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
fn react_fixture_url() -> String {
|
||||
format!(
|
||||
"data:text/html;base64,{}",
|
||||
STANDARD.encode(REACT_FIXTURE_HTML)
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_react_tree_errors_without_hook() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Without --enable react-devtools, the hook isn't installed and the
|
||||
// command should error.
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await;
|
||||
let err = resp
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("React DevTools") || err.contains("renderer"),
|
||||
"Expected hook-missing error, got: {:?}",
|
||||
resp
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_react_tree_with_enable_hook() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_ENABLE"]);
|
||||
guard.set("AGENT_BROWSER_ENABLE", "react-devtools");
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": &react_fixture_url() }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Give React a moment to boot and register with the hook.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let tree = get_data(&resp)
|
||||
.get("tree")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
tree.contains("App"),
|
||||
"Expected tree to contain 'App': {}",
|
||||
tree
|
||||
);
|
||||
assert!(
|
||||
tree.contains("Counter"),
|
||||
"Expected tree to contain 'Counter': {}",
|
||||
tree
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_vitals_reports_metrics() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": &react_fixture_url() }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "vitals" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let report = get_data(&resp)
|
||||
.get("report")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
report.contains("Core Web Vitals"),
|
||||
"Expected vitals report, got: {}",
|
||||
report
|
||||
);
|
||||
assert!(report.contains("TTFB"));
|
||||
assert!(report.contains("CLS"));
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_pushstate_changes_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com/" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "3", "action": "pushstate", "url": "/newpath" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let url = get_data(&resp)
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
url.ends_with("/newpath"),
|
||||
"Expected pushstate URL to end with /newpath, got: {}",
|
||||
url
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_removeinitscript_roundtrip() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "3",
|
||||
"action": "addinitscript",
|
||||
"script": "window.__AB_ROUNDTRIP__ = 1;"
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let identifier = get_data(&resp)["identifier"]
|
||||
.as_str()
|
||||
.expect("addinitscript should return an identifier")
|
||||
.to_string();
|
||||
assert!(!identifier.is_empty());
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "4", "action": "removeinitscript", "identifier": identifier }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
assert_eq!(get_data(&resp)["removed"], true);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user