feat: realign zhihu browser callback runtime
Keep Zhihu browser-attached execution on the callback-host path so direct routes, runtime wiring, and service startup stay aligned for the current websocket browser flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@ const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const HELPER_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const HELPER_BOOTSTRAP_ACTION: &str = "sgBrowerserOpenPage";
|
||||
const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded";
|
||||
const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
||||
const CLICK_CALLBACK_NAME: &str = "sgclawOnClick";
|
||||
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
||||
const TYPE_CALLBACK_NAME: &str = "sgclawOnType";
|
||||
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
||||
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
||||
|
||||
@@ -196,6 +200,15 @@ impl BrowserCallbackHost {
|
||||
pub(crate) fn acknowledge_in_flight_command(&self) -> Option<CallbackCommand> {
|
||||
self.state.lock().unwrap().in_flight_command.take()
|
||||
}
|
||||
|
||||
/// Clear all pending state so the host can be reused for the next task
|
||||
/// without reopening the helper page.
|
||||
pub(crate) fn reset_pending_state(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.pending_results.clear();
|
||||
state.pending_commands.clear();
|
||||
state.in_flight_command = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveBrowserCallbackHost {
|
||||
@@ -241,6 +254,25 @@ impl LiveBrowserCallbackHost {
|
||||
pub(crate) fn helper_url(&self) -> &str {
|
||||
self.host.helper_url()
|
||||
}
|
||||
|
||||
pub(crate) fn reset_pending_state(&self) {
|
||||
self.host.reset_pending_state();
|
||||
}
|
||||
}
|
||||
|
||||
fn command_is_fire_and_forget(request: &BrowserCallbackRequest) -> bool {
|
||||
if request.action == "navigate" {
|
||||
return true;
|
||||
}
|
||||
|
||||
request
|
||||
.command
|
||||
.as_array()
|
||||
.and_then(|items| items.get(1))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|opcode| {
|
||||
opcode == "sgBroewserSimulateMouse" || opcode == "sgBroewserSimulateKeyborad"
|
||||
})
|
||||
}
|
||||
|
||||
impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
||||
@@ -250,10 +282,11 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
||||
self.host.enqueue_command(command_from_request(&request.command)?);
|
||||
|
||||
// Navigate uses sgBrowerserOpenPage which opens a new tab without a JS
|
||||
// callback. We only wait long enough for the helper page to pick up the
|
||||
// command via its 250 ms poll interval and forward it over WebSocket.
|
||||
// The caller (workflow executor) polls for page readiness separately.
|
||||
let is_fire_and_forget = request.action == "navigate";
|
||||
// callback. Simulated mouse/keyboard follow-up commands also do not emit
|
||||
// a helper-page callback; the caller validates their effect with a later
|
||||
// eval/get-text step. We only wait long enough for the helper page poller
|
||||
// to ACK and forward those commands.
|
||||
let is_fire_and_forget = command_is_fire_and_forget(&request);
|
||||
let timeout = if is_fire_and_forget {
|
||||
Duration::from_millis(1500)
|
||||
} else {
|
||||
@@ -635,6 +668,33 @@ fn normalize_callback_result(
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
"click" if result.callback == CLICK_PROBE_CALLBACK_NAME => {
|
||||
let x = result.payload.get("x").and_then(Value::as_f64)?;
|
||||
let y = result.payload.get("y").and_then(Value::as_f64)?;
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"probe": { "x": x, "y": y },
|
||||
"callback": CLICK_CALLBACK_NAME,
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
"type" if result.callback == TYPE_PROBE_CALLBACK_NAME => {
|
||||
let x = result.payload.get("x").and_then(Value::as_f64)?;
|
||||
let y = result.payload.get("y").and_then(Value::as_f64)?;
|
||||
let text = result.payload.get("text").and_then(Value::as_str).unwrap_or_default();
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"probe": { "x": x, "y": y, "text": text },
|
||||
"callback": TYPE_CALLBACK_NAME,
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
// Path A: The browser's native callBackJsToCpp routes the callback to
|
||||
// the helper page and calls sgclawOnGetText / sgclawOnEval directly.
|
||||
// The helper page POSTs to the events endpoint with the callback name
|
||||
@@ -661,7 +721,7 @@ fn normalize_callback_result(
|
||||
// callBackJsToCpp function with the @_@ delimited string. The helper
|
||||
// page parses it and POSTs to the events endpoint with callback:
|
||||
// "callBackJsToCpp" and payload: { raw: "..." }.
|
||||
"getText" | "eval" if result.callback == "callBackJsToCpp" => {
|
||||
"getText" | "eval" | "click" | "type" if result.callback == "callBackJsToCpp" => {
|
||||
let raw = result.payload.get("raw").and_then(Value::as_str)?;
|
||||
let parsed = match parse_callback_js_payload(raw) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -676,12 +736,46 @@ fn normalize_callback_result(
|
||||
if parsed.callback != expected_callback {
|
||||
return None;
|
||||
}
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({ "text": parsed.response_text }),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
match request.action.as_str() {
|
||||
"click" => {
|
||||
let probe: Value = serde_json::from_str(&parsed.response_text).ok()?;
|
||||
let x = probe.get("x").and_then(Value::as_f64)?;
|
||||
let y = probe.get("y").and_then(Value::as_f64)?;
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"probe": { "x": x, "y": y },
|
||||
"callback": CLICK_CALLBACK_NAME,
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
"type" => {
|
||||
let probe: Value = serde_json::from_str(&parsed.response_text).ok()?;
|
||||
let x = probe.get("x").and_then(Value::as_f64)?;
|
||||
let y = probe.get("y").and_then(Value::as_f64)?;
|
||||
let text = probe.get("text").and_then(Value::as_str).unwrap_or_default();
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"probe": { "x": x, "y": y, "text": text },
|
||||
"callback": TYPE_CALLBACK_NAME,
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
// getText / eval — return raw text
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({ "text": parsed.response_text }),
|
||||
aom_snapshot: vec![],
|
||||
timing: elapsed_timing(elapsed),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -713,6 +807,8 @@ fn parse_callback_js_payload(raw: &str) -> Result<ParsedCallbackJsPayload, Strin
|
||||
fn expected_callback_name(action: &str) -> Result<&'static str, PipeError> {
|
||||
match action {
|
||||
"navigate" => Ok(NAVIGATE_CALLBACK_NAME),
|
||||
"click" => Ok(CLICK_PROBE_CALLBACK_NAME),
|
||||
"type" => Ok(TYPE_PROBE_CALLBACK_NAME),
|
||||
"getText" => Ok(GET_TEXT_CALLBACK_NAME),
|
||||
"eval" => Ok(EVAL_CALLBACK_NAME),
|
||||
other => Err(PipeError::Protocol(format!(
|
||||
@@ -731,12 +827,28 @@ fn elapsed_timing(elapsed: Duration) -> Timing {
|
||||
fn build_helper_page_html(loopback_origin: &str, helper_url: &str, browser_ws_url: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<title>sgClaw Browser Helper</title>
|
||||
</head>
|
||||
<html><head><meta charset="utf-8"/><title>sgClaw · Runtime Console</title>
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#0b1120;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;height:100vh;overflow:hidden;display:flex;flex-direction:column}}
|
||||
.hd{{padding:20px 24px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:16px}}
|
||||
.logo{{width:36px;height:36px;background:linear-gradient(135deg,#3b82f6,#06b6d4);border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;color:#fff;flex-shrink:0}}
|
||||
.ti{{flex:1}}.ti h1{{font-size:16px;font-weight:600;color:#f1f5f9}}.ti p{{font-size:12px;color:#64748b;margin-top:2px}}
|
||||
.sb{{display:flex;align-items:center;gap:6px;font-size:12px;color:#94a3b8}}
|
||||
.sd{{width:8px;height:8px;border-radius:50%;background:#475569}}.sd.on{{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5);animation:pulse 2s ease-in-out infinite}}
|
||||
@keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.5}}}}
|
||||
.stats{{padding:14px 24px;display:flex;gap:32px;border-bottom:1px solid #1e293b}}.st{{display:flex;flex-direction:column;gap:2px}}.st .l{{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.5px}}.st .v{{font-size:20px;font-weight:600;color:#e2e8f0;font-variant-numeric:tabular-nums}}
|
||||
.tb{{padding:12px 24px;background:#111827;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:12px}}.sp{{width:16px;height:16px;border:2px solid #1e293b;border-top-color:#3b82f6;border-radius:50%;animation:spin 1s linear infinite}}@keyframes spin{{to{{transform:rotate(360deg)}}}}.tb .tl{{font-size:12px;color:#64748b}}.tb .tt{{font-size:14px;color:#e2e8f0}}
|
||||
.log{{flex:1;padding:16px 24px;overflow-y:auto;font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:13px;line-height:1.7}}.le{{color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}.le .t{{color:#475569}}.le .a{{color:#3b82f6}}.le .u{{color:#06b6d4}}.le .ok{{color:#22c55e}}.le .er{{color:#ef4444}}
|
||||
.ft{{padding:12px 24px;border-top:1px solid #1e293b;font-size:11px;color:#475569;display:flex;justify-content:space-between}}
|
||||
::-webkit-scrollbar{{width:6px}}::-webkit-scrollbar-track{{background:transparent}}::-webkit-scrollbar-thumb{{background:#1e293b;border-radius:3px}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="hd"><div class="logo">sg</div><div class="ti"><h1>sgClaw · Runtime Console</h1><p>Browser Automation Agent</p></div><div class="sb"><div class="sd" id="sd"></div><span id="stx">Connecting…</span></div></div>
|
||||
<div class="stats"><div class="st"><div class="l">Commands</div><div class="v" id="nc">0</div></div><div class="st"><div class="l">Callbacks</div><div class="v" id="nb">0</div></div><div class="st"><div class="l">Uptime</div><div class="v" id="ut">0s</div></div></div>
|
||||
<div class="tb" id="tb"><div class="sp"></div><div><div class="tl">Current</div><div class="tt" id="tt">Initializing…</div></div></div>
|
||||
<div class="log" id="lg"></div>
|
||||
<div class="ft"><span>sgClaw v0.1 · Browser Callback Host</span><span id="wi"></span></div>
|
||||
<script>
|
||||
const SGCLAW_LOOPBACK_ORIGIN = {loopback_origin:?};
|
||||
const SGCLAW_HELPER_URL = {helper_url:?};
|
||||
@@ -746,6 +858,13 @@ const SGCLAW_EVENTS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{EVENTS_ENDPOINT_PATH
|
||||
const SGCLAW_COMMANDS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMANDS_ENDPOINT_PATH}`;
|
||||
const SGCLAW_COMMAND_ACK_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMAND_ACK_ENDPOINT_PATH}`;
|
||||
|
||||
var _nc=0,_nb=0,_t0=Date.now(),_lastCmd=0,_idle=true;
|
||||
function _log(msg){{var d=new Date();var ts=[d.getHours(),d.getMinutes(),d.getSeconds()].map(function(v){{return v<10?'0'+v:v;}}).join(':');var el=document.getElementById('lg');var r=document.createElement('div');r.className='le';r.innerHTML='<span class="t">'+ts+'</span> '+msg;el.appendChild(r);el.scrollTop=el.scrollHeight;if(el.children.length>200)el.removeChild(el.children[0]);}}
|
||||
function _task(t){{document.getElementById('tt').textContent=t;}}
|
||||
function _setIdle(v){{if(_idle===v)return;_idle=v;var sp=document.querySelector('.sp');var tl=document.querySelector('.tb .tl');var tt=document.getElementById('tt');if(v){{sp.style.borderTopColor='#22c55e';sp.style.animation='none';tl.textContent='Status';tt.textContent='Ready \u2014 waiting for commands';tt.style.color='#22c55e';}}else{{sp.style.borderTopColor='';sp.style.animation='';tl.textContent='Current';tt.style.color='';}}}}
|
||||
function _stat(){{document.getElementById('nc').textContent=_nc;document.getElementById('nb').textContent=_nb;var s=Math.floor((Date.now()-_t0)/1000);var m=Math.floor(s/60);s=s%60;document.getElementById('ut').textContent=m>0?m+'m '+s+'s':s+'s';if(!_idle&&_lastCmd>0&&Date.now()-_lastCmd>3000)_setIdle(true);}}
|
||||
setInterval(_stat,1000);
|
||||
|
||||
async function sgclawPostJson(url, body) {{
|
||||
await fetch(url, {{
|
||||
method: 'POST',
|
||||
@@ -762,6 +881,7 @@ async function sgclawReady() {{
|
||||
}}
|
||||
|
||||
async function sgclawEmitCallback(callback, payload, extra) {{
|
||||
_nb++;_lastCmd=Date.now();_log('<span class="ok">\u2190</span> callback <span class="a">'+callback+'</span>');
|
||||
await sgclawPostJson(SGCLAW_EVENTS_ENDPOINT, Object.assign({{
|
||||
type: 'callback',
|
||||
callback,
|
||||
@@ -771,14 +891,32 @@ async function sgclawEmitCallback(callback, payload, extra) {{
|
||||
}}
|
||||
|
||||
function sgclawOnLoaded(targetUrl) {{
|
||||
_task('Page loaded');
|
||||
return sgclawEmitCallback('sgclawOnLoaded', {{ loaded: true }}, {{ target_url: targetUrl || null }});
|
||||
}}
|
||||
|
||||
function sgclawOnClickProbe(x, y) {{
|
||||
return sgclawEmitCallback('sgclawOnClickProbe', {{ x: Number(x) || 0, y: Number(y) || 0 }});
|
||||
}}
|
||||
|
||||
function sgclawOnClick() {{
|
||||
return sgclawEmitCallback('sgclawOnClick', {{ clicked: true }});
|
||||
}}
|
||||
|
||||
function sgclawOnTypeProbe(x, y, text) {{
|
||||
return sgclawEmitCallback('sgclawOnTypeProbe', {{ x: Number(x) || 0, y: Number(y) || 0, text: text ?? '' }});
|
||||
}}
|
||||
|
||||
function sgclawOnType() {{
|
||||
return sgclawEmitCallback('sgclawOnType', {{ typed: true }});
|
||||
}}
|
||||
|
||||
function sgclawOnGetText(text, targetUrl) {{
|
||||
return sgclawEmitCallback('sgclawOnGetText', {{ text: text ?? null }}, {{ target_url: targetUrl || null }});
|
||||
}}
|
||||
|
||||
function sgclawOnEval(value, targetUrl) {{
|
||||
_task('Eval complete');
|
||||
return sgclawEmitCallback('sgclawOnEval', {{ value: value ?? null }}, {{ target_url: targetUrl || null }});
|
||||
}}
|
||||
|
||||
@@ -791,20 +929,38 @@ function callBackJsToCpp(param) {{
|
||||
}}
|
||||
|
||||
window.sgclawOnLoaded = sgclawOnLoaded;
|
||||
window.sgclawOnClickProbe = sgclawOnClickProbe;
|
||||
window.sgclawOnClick = sgclawOnClick;
|
||||
window.sgclawOnTypeProbe = sgclawOnTypeProbe;
|
||||
window.sgclawOnType = sgclawOnType;
|
||||
window.sgclawOnGetText = sgclawOnGetText;
|
||||
window.sgclawOnEval = sgclawOnEval;
|
||||
window.callBackJsToCpp = callBackJsToCpp;
|
||||
|
||||
document.getElementById('wi').textContent = SGCLAW_BROWSER_WS_URL;
|
||||
_log('Connecting to browser WebSocket\u2026');
|
||||
|
||||
const sgclawSocket = new WebSocket(SGCLAW_BROWSER_WS_URL);
|
||||
sgclawSocket.addEventListener('open', async () => {{
|
||||
document.getElementById('sd').classList.add('on');
|
||||
document.getElementById('stx').textContent = 'Connected';
|
||||
_log('<span class="ok">\u2713</span> WebSocket connected');
|
||||
_task('Connected to browser');
|
||||
sgclawSocket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
|
||||
await sgclawReady();
|
||||
_log('<span class="ok">\u2713</span> Ready signal sent');
|
||||
_task('Ready \u2014 waiting for commands');
|
||||
}});
|
||||
|
||||
sgclawSocket.addEventListener('close', () => {{
|
||||
document.getElementById('sd').classList.remove('on');
|
||||
document.getElementById('stx').textContent = 'Disconnected';
|
||||
_log('<span class="er">\u2717</span> WebSocket disconnected');
|
||||
_task('Disconnected');
|
||||
}});
|
||||
|
||||
sgclawSocket.addEventListener('message', (event) => {{
|
||||
console.debug('sgclaw helper received browser frame', event.data);
|
||||
// If the browser routes a callBackJsToCpp result back over WebSocket,
|
||||
// parse it and forward to the callback host events endpoint.
|
||||
try {{
|
||||
var data = String(event.data || '');
|
||||
if (data.indexOf('@_@') !== -1) {{
|
||||
@@ -824,7 +980,11 @@ async function sgclawPollCommands() {{
|
||||
if (!command || !command.action) {{
|
||||
return;
|
||||
}}
|
||||
_nc++;
|
||||
const args = Array.isArray(command.args) ? command.args : [];
|
||||
_lastCmd=Date.now();_setIdle(false);
|
||||
_log('<span class="a">\u2192</span> execute <span class="a">'+command.action+'</span>'+(args.length>1?' <span class="u">'+String(args[1]||'').substring(0,50)+'</span>':''));
|
||||
_task('Executing: '+command.action);
|
||||
if (sgclawSocket.readyState !== WebSocket.OPEN) {{
|
||||
return;
|
||||
}}
|
||||
@@ -835,6 +995,7 @@ async function sgclawPollCommands() {{
|
||||
}}
|
||||
|
||||
setInterval(sgclawPollCommands, 250);
|
||||
_log('sgClaw Runtime Console initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -945,6 +1106,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_callback_host_treats_simulated_mouse_command_as_fire_and_forget() {
|
||||
use crate::browser::callback_backend::{
|
||||
BrowserCallbackHost as BrowserCallbackExecutor, BrowserCallbackRequest,
|
||||
};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
let host = LiveBrowserCallbackHost {
|
||||
host: Arc::new(BrowserCallbackHost::new()),
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
server_thread: Mutex::new(None),
|
||||
command_lock: Mutex::new(()),
|
||||
result_timeout: Duration::from_millis(10),
|
||||
};
|
||||
|
||||
let response = host.execute(BrowserCallbackRequest {
|
||||
seq: 1,
|
||||
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||
expected_domain: "zhuanlan.zhihu.com".to_string(),
|
||||
action: "click".to_string(),
|
||||
command: json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateMouse",
|
||||
320.5,
|
||||
240.25,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]),
|
||||
});
|
||||
|
||||
assert!(response.is_ok(), "simulated mouse follow-up should not wait for a callback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_host_exposes_loopback_helper_url_and_release_helper_html() {
|
||||
let host = BrowserCallbackHost::new();
|
||||
@@ -959,6 +1154,10 @@ mod tests {
|
||||
assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#));
|
||||
assert!(html.contains("sgclawReady"));
|
||||
assert!(html.contains("sgclawOnLoaded"));
|
||||
assert!(html.contains("sgclawOnClickProbe"));
|
||||
assert!(html.contains("sgclawOnClick"));
|
||||
assert!(html.contains("sgclawOnTypeProbe"));
|
||||
assert!(html.contains("sgclawOnType"));
|
||||
assert!(html.contains("sgclawOnGetText"));
|
||||
assert!(html.contains("sgclawOnEval"));
|
||||
assert!(html.contains("/sgclaw/callback/ready"));
|
||||
@@ -1102,4 +1301,107 @@ mod tests {
|
||||
);
|
||||
assert!(host.acknowledge_in_flight_command().is_none());
|
||||
}
|
||||
|
||||
// ── Path B callBackJsToCpp normalization tests ────────────────────
|
||||
|
||||
use super::normalize_callback_result;
|
||||
use crate::browser::callback_backend::BrowserCallbackRequest;
|
||||
|
||||
fn make_request(action: &str) -> BrowserCallbackRequest {
|
||||
BrowserCallbackRequest {
|
||||
seq: 1,
|
||||
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||
expected_domain: "zhuanlan.zhihu.com".to_string(),
|
||||
action: action.to_string(),
|
||||
command: json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
"zhuanlan.zhihu.com",
|
||||
"(function(){ /* probe */ })()"
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_callback_js_to_cpp_result(raw: &str) -> CallbackResult {
|
||||
CallbackResult {
|
||||
callback: "callBackJsToCpp".to_string(),
|
||||
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||
target_url: Some("https://zhuanlan.zhihu.com/write".to_string()),
|
||||
action: Some("sgBrowserExcuteJsCodeByDomain".to_string()),
|
||||
payload: json!({ "raw": raw }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_callback_result_path_b_click_probe() {
|
||||
let request = make_request("click");
|
||||
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnClickProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":320.5,\"y\":240.25}";
|
||||
let result = make_callback_js_to_cpp_result(raw);
|
||||
|
||||
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||
assert!(response.is_some(), "Path B click should produce a response");
|
||||
match response.unwrap() {
|
||||
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||
let probe = s.data.get("probe").expect("should have probe");
|
||||
assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 320.5);
|
||||
assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 240.25);
|
||||
assert_eq!(
|
||||
s.data.get("callback").unwrap().as_str().unwrap(),
|
||||
"sgclawOnClick"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_callback_result_path_b_type_probe() {
|
||||
let request = make_request("type");
|
||||
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":100,\"y\":200,\"text\":\"hello\"}";
|
||||
let result = make_callback_js_to_cpp_result(raw);
|
||||
|
||||
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||
assert!(response.is_some(), "Path B type should produce a response");
|
||||
match response.unwrap() {
|
||||
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||
let probe = s.data.get("probe").expect("should have probe");
|
||||
assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 100.0);
|
||||
assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 200.0);
|
||||
assert_eq!(probe.get("text").unwrap().as_str().unwrap(), "hello");
|
||||
assert_eq!(
|
||||
s.data.get("callback").unwrap().as_str().unwrap(),
|
||||
"sgclawOnType"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_callback_result_path_b_click_wrong_callback_returns_none() {
|
||||
let request = make_request("click");
|
||||
// callback name is sgclawOnTypeProbe (wrong for click action)
|
||||
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":1,\"y\":2}";
|
||||
let result = make_callback_js_to_cpp_result(raw);
|
||||
|
||||
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||
assert!(response.is_none(), "mismatched callback name should return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_callback_result_path_b_eval_still_works() {
|
||||
let request = make_request("eval");
|
||||
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnEval@_@sgBrowserExcuteJsCodeByDomain@_@{\"status\":\"ok\"}";
|
||||
let result = make_callback_js_to_cpp_result(raw);
|
||||
|
||||
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||
assert!(response.is_some(), "Path B eval should still work");
|
||||
match response.unwrap() {
|
||||
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||
let text = s.data.get("text").unwrap().as_str().unwrap();
|
||||
assert_eq!(text, r#"{"status":"ok"}"#);
|
||||
}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user