538 lines
17 KiB
Rust
538 lines
17 KiB
Rust
use std::net::TcpListener;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use tungstenite::{accept, Message};
|
|
|
|
#[path = "../src/browser/ws_probe.rs"]
|
|
mod ws_probe;
|
|
|
|
use ws_probe::{
|
|
parse_probe_args, run_probe_script, ProbeCliConfig, ProbeOutcome, ProbeStep, ProbeStepResult,
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
enum ServerStep {
|
|
ReceiveThenReply {
|
|
expected: String,
|
|
reply: String,
|
|
},
|
|
ReceiveThenReplyFrames {
|
|
expected: String,
|
|
replies: Vec<String>,
|
|
},
|
|
ReceiveThenStaySilent {
|
|
expected: String,
|
|
},
|
|
ReceiveThenClose {
|
|
expected: String,
|
|
},
|
|
CloseBeforeReceive,
|
|
}
|
|
|
|
fn spawn_fake_server(
|
|
script: Vec<ServerStep>,
|
|
) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let received = Arc::new(Mutex::new(Vec::new()));
|
|
let received_for_thread = received.clone();
|
|
|
|
let handle = thread::spawn(move || {
|
|
let (stream, _) = listener.accept().unwrap();
|
|
let mut socket = accept(stream).unwrap();
|
|
|
|
for step in script {
|
|
match step {
|
|
ServerStep::CloseBeforeReceive => {
|
|
socket.close(None).unwrap();
|
|
return;
|
|
}
|
|
ServerStep::ReceiveThenReply { expected, reply } => {
|
|
let message = socket.read().unwrap();
|
|
let payload = match message {
|
|
Message::Text(text) => text.to_string(),
|
|
other => panic!("expected text frame, got {other:?}"),
|
|
};
|
|
received_for_thread.lock().unwrap().push(payload.clone());
|
|
assert_eq!(payload, expected);
|
|
socket.send(Message::Text(reply.into())).unwrap();
|
|
}
|
|
ServerStep::ReceiveThenReplyFrames { expected, replies } => {
|
|
let message = socket.read().unwrap();
|
|
let payload = match message {
|
|
Message::Text(text) => text.to_string(),
|
|
other => panic!("expected text frame, got {other:?}"),
|
|
};
|
|
received_for_thread.lock().unwrap().push(payload.clone());
|
|
assert_eq!(payload, expected);
|
|
for reply in replies {
|
|
socket.send(Message::Text(reply.into())).unwrap();
|
|
}
|
|
}
|
|
ServerStep::ReceiveThenStaySilent { expected } => {
|
|
let message = socket.read().unwrap();
|
|
let payload = match message {
|
|
Message::Text(text) => text.to_string(),
|
|
other => panic!("expected text frame, got {other:?}"),
|
|
};
|
|
received_for_thread.lock().unwrap().push(payload.clone());
|
|
assert_eq!(payload, expected);
|
|
thread::sleep(Duration::from_millis(120));
|
|
}
|
|
ServerStep::ReceiveThenClose { expected } => {
|
|
let message = socket.read().unwrap();
|
|
let payload = match message {
|
|
Message::Text(text) => text.to_string(),
|
|
other => panic!("expected text frame, got {other:?}"),
|
|
};
|
|
received_for_thread.lock().unwrap().push(payload.clone());
|
|
assert_eq!(payload, expected);
|
|
socket.close(None).unwrap();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
(format!("ws://{addr}"), received, handle)
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_rejects_non_ws_schemes() {
|
|
let cases = [
|
|
"wss://127.0.0.1:12345",
|
|
"http://127.0.0.1:12345",
|
|
"127.0.0.1:12345",
|
|
];
|
|
|
|
for ws_url in cases {
|
|
let args = vec![
|
|
"--ws-url".to_string(),
|
|
ws_url.to_string(),
|
|
"--timeout-ms".to_string(),
|
|
"1500".to_string(),
|
|
"--step".to_string(),
|
|
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
];
|
|
|
|
let err = parse_probe_args(&args).unwrap_err();
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!(
|
|
"probe argument error: unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
|
|
let args = vec![
|
|
"--ws-url".to_string(),
|
|
"ws://127.0.0.1:12345".to_string(),
|
|
"--timeout-ms".to_string(),
|
|
"1500".to_string(),
|
|
"--step".to_string(),
|
|
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
"--step".to_string(),
|
|
"open-hot::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
|
.to_string(),
|
|
];
|
|
|
|
let parsed = parse_probe_args(&args).unwrap();
|
|
|
|
assert_eq!(
|
|
parsed,
|
|
ProbeCliConfig {
|
|
ws_url: "ws://127.0.0.1:12345".to_string(),
|
|
timeout_ms: 1500,
|
|
steps: vec![
|
|
ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "open-hot".to_string(),
|
|
payload:
|
|
"[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
|
.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_defaults_register_step_when_step_is_omitted() {
|
|
let args = vec!["--ws-url".to_string(), "ws://127.0.0.1:12345".to_string()];
|
|
|
|
let parsed = parse_probe_args(&args).unwrap();
|
|
|
|
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
|
assert_eq!(parsed.timeout_ms, 1500);
|
|
assert_eq!(
|
|
parsed.steps,
|
|
vec![ProbeStep {
|
|
label: "register".to_string(),
|
|
payload: r#"{"type":"register","role":"web"}"#.to_string(),
|
|
expect_reply: true,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_defaults_timeout_when_flag_is_omitted() {
|
|
let args = vec![
|
|
"--ws-url".to_string(),
|
|
"ws://127.0.0.1:12345".to_string(),
|
|
"--step".to_string(),
|
|
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
];
|
|
|
|
let parsed = parse_probe_args(&args).unwrap();
|
|
|
|
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
|
assert_eq!(parsed.timeout_ms, 1500);
|
|
assert_eq!(
|
|
parsed.steps,
|
|
vec![ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
expect_reply: true,
|
|
}]
|
|
);
|
|
}
|
|
|
|
fn temp_step_file(name: &str, contents: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
path.push(format!("sgclaw-ws-probe-{name}-{nanos}.txt"));
|
|
std::fs::write(&path, contents).unwrap();
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_accepts_step_file() {
|
|
let step_file = temp_step_file(
|
|
"single",
|
|
"open-agent::[\"about:blank\",\"sgOpenAgent\"]\n",
|
|
);
|
|
let args = vec![
|
|
"--ws-url".to_string(),
|
|
"ws://127.0.0.1:12345".to_string(),
|
|
"--step-file".to_string(),
|
|
step_file.display().to_string(),
|
|
];
|
|
|
|
let parsed = parse_probe_args(&args).unwrap();
|
|
|
|
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
|
assert_eq!(parsed.timeout_ms, 1500);
|
|
assert_eq!(
|
|
parsed.steps,
|
|
vec![ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
expect_reply: true,
|
|
}]
|
|
);
|
|
|
|
let _ = std::fs::remove_file(step_file);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_probe_args_preserves_step_and_step_file_order() {
|
|
let step_file = temp_step_file(
|
|
"ordered",
|
|
"open-hot::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]\n",
|
|
);
|
|
let args = vec![
|
|
"--ws-url".to_string(),
|
|
"ws://127.0.0.1:12345".to_string(),
|
|
"--step".to_string(),
|
|
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
"--step-file".to_string(),
|
|
step_file.display().to_string(),
|
|
];
|
|
|
|
let parsed = parse_probe_args(&args).unwrap();
|
|
|
|
assert_eq!(
|
|
parsed.steps,
|
|
vec![
|
|
ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "open-hot".to_string(),
|
|
payload:
|
|
"[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
|
.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
]
|
|
);
|
|
|
|
let _ = std::fs::remove_file(step_file);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_records_welcome_then_silence_transcript() {
|
|
let steps = vec![
|
|
ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "await-followup".to_string(),
|
|
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
];
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![
|
|
ServerStep::ReceiveThenReply {
|
|
expected: steps[0].payload.clone(),
|
|
reply: "Welcome! You are client #1".to_string(),
|
|
},
|
|
ServerStep::ReceiveThenStaySilent {
|
|
expected: steps[1].payload.clone(),
|
|
},
|
|
]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().clone(),
|
|
steps
|
|
.iter()
|
|
.map(|step| step.payload.clone())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(
|
|
results,
|
|
vec![
|
|
ProbeStepResult {
|
|
label: "open-agent".to_string(),
|
|
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
outcome: ProbeOutcome::Received(vec!["Welcome! You are client #1".to_string()]),
|
|
},
|
|
ProbeStepResult {
|
|
label: "await-followup".to_string(),
|
|
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
outcome: ProbeOutcome::TimedOut,
|
|
},
|
|
]
|
|
);
|
|
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
|
|
let steps = vec![
|
|
ProbeStep {
|
|
label: "bootstrap-1".to_string(),
|
|
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "bootstrap-2".to_string(),
|
|
payload: r#"["about:blank","sgSetAuthInfo","probe-user","probe-token"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "action".to_string(),
|
|
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#
|
|
.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
];
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![
|
|
ServerStep::ReceiveThenReply {
|
|
expected: steps[0].payload.clone(),
|
|
reply: "welcome".to_string(),
|
|
},
|
|
ServerStep::ReceiveThenReply {
|
|
expected: steps[1].payload.clone(),
|
|
reply: "0".to_string(),
|
|
},
|
|
ServerStep::ReceiveThenStaySilent {
|
|
expected: steps[2].payload.clone(),
|
|
},
|
|
]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().clone(),
|
|
steps
|
|
.iter()
|
|
.map(|step| step.payload.clone())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(results.len(), 3);
|
|
assert_eq!(results[0].label, "bootstrap-1");
|
|
assert_eq!(
|
|
results[0].outcome,
|
|
ProbeOutcome::Received(vec!["welcome".to_string()])
|
|
);
|
|
assert_eq!(results[1].label, "bootstrap-2");
|
|
assert_eq!(
|
|
results[1].outcome,
|
|
ProbeOutcome::Received(vec!["0".to_string()])
|
|
);
|
|
assert_eq!(results[2].label, "action");
|
|
assert_eq!(
|
|
results[2].sent,
|
|
r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#
|
|
);
|
|
assert_eq!(results[2].outcome, ProbeOutcome::TimedOut);
|
|
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn probe_records_multiple_frames_for_one_step_within_timeout_window() {
|
|
let steps = vec![ProbeStep {
|
|
label: "bootstrap".to_string(),
|
|
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
expect_reply: true,
|
|
}];
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenReplyFrames {
|
|
expected: steps[0].payload.clone(),
|
|
replies: vec!["welcome".to_string(), "status:ready".to_string()],
|
|
}]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().as_slice(),
|
|
[steps[0].payload.as_str()]
|
|
);
|
|
assert_eq!(
|
|
results,
|
|
vec![ProbeStepResult {
|
|
label: "bootstrap".to_string(),
|
|
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
outcome: ProbeOutcome::Received(vec![
|
|
"welcome".to_string(),
|
|
"status:ready".to_string(),
|
|
]),
|
|
}]
|
|
);
|
|
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn probe_records_steps_that_do_not_wait_for_reply_without_ambiguity() {
|
|
let steps = vec![ProbeStep {
|
|
label: "fire-and-forget".to_string(),
|
|
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
expect_reply: false,
|
|
}];
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
|
|
expected: steps[0].payload.clone(),
|
|
}]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
|
|
|
handle.join().unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().as_slice(),
|
|
[steps[0].payload.as_str()]
|
|
);
|
|
assert_eq!(
|
|
results,
|
|
vec![ProbeStepResult {
|
|
label: "fire-and-forget".to_string(),
|
|
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
outcome: ProbeOutcome::NoReplyExpected,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_records_close_when_server_closes_before_next_send() {
|
|
let steps = vec![
|
|
ProbeStep {
|
|
label: "open-agent".to_string(),
|
|
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
ProbeStep {
|
|
label: "follow-up".to_string(),
|
|
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
expect_reply: true,
|
|
},
|
|
];
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![
|
|
ServerStep::ReceiveThenReply {
|
|
expected: steps[0].payload.clone(),
|
|
reply: "welcome".to_string(),
|
|
},
|
|
ServerStep::CloseBeforeReceive,
|
|
]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().as_slice(),
|
|
[steps[0].payload.as_str()]
|
|
);
|
|
assert_eq!(
|
|
results,
|
|
vec![
|
|
ProbeStepResult {
|
|
label: "open-agent".to_string(),
|
|
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
outcome: ProbeOutcome::Received(vec!["welcome".to_string()]),
|
|
},
|
|
ProbeStepResult {
|
|
label: "follow-up".to_string(),
|
|
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
|
outcome: ProbeOutcome::Closed,
|
|
},
|
|
]
|
|
);
|
|
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn probe_reports_socket_close_separately_from_timeout() {
|
|
let step = ProbeStep {
|
|
label: "close-case".to_string(),
|
|
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
|
expect_reply: true,
|
|
};
|
|
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenClose {
|
|
expected: step.payload.clone(),
|
|
}]);
|
|
|
|
let results = run_probe_script(&ws_url, Duration::from_millis(40), vec![step]).unwrap();
|
|
|
|
assert_eq!(
|
|
received.lock().unwrap().as_slice(),
|
|
[r#"["about:blank","sgOpenAgent"]"#]
|
|
);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].label, "close-case");
|
|
assert_eq!(results[0].outcome, ProbeOutcome::Closed);
|
|
|
|
handle.join().unwrap();
|
|
}
|