Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"crates/weaver-graph",
"crates/weaverd",
"crates/weaver-lsp-host",
"crates/weaver-plugin-rope",
"crates/weaver-plugins",
"crates/weaver-sandbox",
"crates/weaver-syntax",
Expand Down
1 change: 1 addition & 0 deletions crates/weaver-e2e/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ camino = { workspace = true }
thiserror = { workspace = true }

[dev-dependencies]
assert_cmd = { workspace = true }
rstest = { workspace = true }
insta = { workspace = true }
tempfile = { workspace = true }
Expand Down
295 changes: 295 additions & 0 deletions crates/weaver-e2e/tests/refactor_rope_cli_snapshots.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
//! End-to-end CLI ergonomics snapshots for `act refactor`.
//!
//! These tests run the `weaver` binary with a fake daemon endpoint to capture
//! user-facing command ergonomics, including a shell pipeline that chains an
//! observe query through `jq` into an actuator command.

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::Output;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::{io, thread};

use assert_cmd::Command;
use insta::assert_debug_snapshot;
use serde::Serialize;
use serde_json::json;

#[derive(Debug, Serialize)]
struct Transcript {
command: String,
status: i32,
stdout: String,
stderr: String,
requests: Vec<serde_json::Value>,
}

#[derive(Debug)]
struct FakeDaemon {
address: SocketAddr,
requests: Arc<Mutex<Vec<serde_json::Value>>>,
join_handle: thread::JoinHandle<()>,
}

#[expect(
deprecated,
reason = "assert_cmd::cargo::cargo_bin resolves workspace binaries for e2e tests"
)]
fn weaver_binary_path() -> std::path::PathBuf {
assert_cmd::cargo::cargo_bin("weaver")
}

impl FakeDaemon {
fn start(expected_requests: usize) -> Result<Self, std::io::Error> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let address = listener.local_addr()?;
let requests = Arc::new(Mutex::new(Vec::new()));
let shared_requests = Arc::clone(&requests);

let join_handle = thread::spawn(move || {
serve_requests(&listener, expected_requests, &shared_requests);
});

Ok(Self {
address,
requests,
join_handle,
})
}

fn endpoint(&self) -> String {
format!("tcp://{}", self.address)
}

#[expect(
clippy::expect_used,
reason = "poisoned mutex in test fixture must surface as panic for clear diagnostics"
)]
fn requests(&self) -> Vec<serde_json::Value> {
self.requests
.lock()
.expect("request mutex should not be poisoned")
.clone()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn join(self) {
assert!(
self.join_handle.join().is_ok(),
"fake daemon thread should not panic"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const ACCEPT_TIMEOUT: Duration = Duration::from_secs(10);
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(10);

#[expect(
clippy::expect_used,
reason = "non-blocking configuration is fundamental to the deadline mechanism"
)]
fn serve_requests(
listener: &TcpListener,
expected_requests: usize,
requests: &Arc<Mutex<Vec<serde_json::Value>>>,
) {
listener
.set_nonblocking(true)
.expect("non-blocking mode should be supported");

for _ in 0..expected_requests {
let Some(stream) = accept_before_deadline(listener) else {
return;
};
if respond_to_request(stream, requests).is_err() {
return;
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Polls `listener.accept()` until a connection arrives or the deadline elapses.
#[expect(
clippy::expect_used,
reason = "restoring blocking mode on accepted stream must succeed for correct I/O"
)]
fn accept_before_deadline(listener: &TcpListener) -> Option<TcpStream> {
let deadline = Instant::now() + ACCEPT_TIMEOUT;

loop {
match listener.accept() {
Ok((stream, _)) => {
stream
.set_nonblocking(false)
.expect("blocking mode should be supported");
return Some(stream);
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"fake daemon timed out waiting for CLI connection \
after {ACCEPT_TIMEOUT:?}"
);
thread::sleep(ACCEPT_POLL_INTERVAL);
}
Err(_) => return None,
}
}
}

fn response_payload_for_operation(operation: &str) -> String {
match operation {
"get-definition" => json!([{ "symbol": "renamed_symbol" }]).to_string(),
"refactor" => json!({
"status": "ok",
"files_written": 1,
"files_deleted": 0
})
.to_string(),
_ => json!({ "status": "unexpected", "operation": operation }).to_string(),
}
}

#[expect(
clippy::expect_used,
reason = "poisoned mutex in test fixture must surface as panic for clear diagnostics"
)]
fn respond_to_request(
stream: TcpStream,
requests: &Arc<Mutex<Vec<serde_json::Value>>>,
) -> Result<(), std::io::Error> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;

let parsed_request: serde_json::Value = serde_json::from_str(request_line.trim())
.unwrap_or_else(|_| {
json!({
"invalid_request": request_line.trim(),
})
});

requests
.lock()
.expect("request mutex should not be poisoned")
.push(parsed_request.clone());

let operation = parsed_request
.get("command")
.and_then(|command| command.get("operation"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();

let payload = response_payload_for_operation(operation);

let mut writer = stream;
write_json_line(
&mut writer,
&json!({
"kind": "stream",
"stream": "stdout",
"data": payload,
}),
)?;
write_json_line(&mut writer, &json!({ "kind": "exit", "status": 0 }))
}

fn write_json_line(
writer: &mut impl Write,
payload: &serde_json::Value,
) -> Result<(), std::io::Error> {
writer.write_all(payload.to_string().as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()
}

fn output_to_transcript(
command: String,
output: &Output,
requests: Vec<serde_json::Value>,
) -> Transcript {
let status = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Transcript {
command,
status,
stdout,
stderr,
requests,
}
}

#[test]
fn refactor_actuator_isolation_cli_snapshot() {
let daemon = FakeDaemon::start(1).expect("fake daemon should start");
let endpoint = daemon.endpoint();

let command_string = String::from(
"weaver --daemon-socket tcp://<daemon-endpoint> --output json act refactor --provider rope --refactoring rename --file src/main.py new_name=renamed_symbol offset=4",
);

let mut command = Command::new(weaver_binary_path());
let output = command
.args([
"--daemon-socket",
endpoint.as_str(),
"--output",
"json",
"act",
"refactor",
"--provider",
"rope",
"--refactoring",
"rename",
"--file",
"src/main.py",
"new_name=renamed_symbol",
"offset=4",
])
.output()
.expect("command should execute");

let transcript = output_to_transcript(command_string, &output, daemon.requests());
daemon.join();

assert_debug_snapshot!("refactor_actuator_isolation", transcript);
}

#[test]
fn refactor_pipeline_with_observe_and_jq_snapshot() {
let jq_available = Command::new("jq").arg("--version").output().is_ok();
if !jq_available {
writeln!(
std::io::stderr().lock(),
"Skipping test: jq not available on PATH"
)
.ok();
return;
}
Comment thread
leynos marked this conversation as resolved.

let daemon = FakeDaemon::start(2).expect("fake daemon should start");
let endpoint = daemon.endpoint();
let weaver_bin = weaver_binary_path();

let shell_script = concat!(
"\"$WEAVER_BIN\" --daemon-socket \"$WEAVER_ENDPOINT\" --output json ",
"observe get-definition --symbol old_symbol ",
"| jq -r '.[0].symbol' ",
"| xargs -I{} \"$WEAVER_BIN\" --daemon-socket \"$WEAVER_ENDPOINT\" --output json ",
"act refactor --provider rope --refactoring rename --file src/main.py new_name={} offset=4"
);

let output = Command::new("bash")
.args(["-c", shell_script])
.env("WEAVER_BIN", weaver_bin)
.env("WEAVER_ENDPOINT", endpoint.as_str())
.output()
.expect("pipeline command should execute");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let command_string =
String::from("weaver observe get-definition | jq -r '.[0].symbol' | weaver act refactor");
let transcript = output_to_transcript(command_string, &output, daemon.requests());
daemon.join();

assert_debug_snapshot!("refactor_pipeline_observe_jq", transcript);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
source: crates/weaver-e2e/tests/refactor_rope_cli_snapshots.rs
expression: transcript
---
Transcript {
command: "weaver --daemon-socket tcp://<daemon-endpoint> --output json act refactor --provider rope --refactoring rename --file src/main.py new_name=renamed_symbol offset=4",
status: 0,
stdout: "{\"files_deleted\":0,\"files_written\":1,\"status\":\"ok\"}",
stderr: "",
requests: [
Object {
"arguments": Array [
String("--provider"),
String("rope"),
String("--refactoring"),
String("rename"),
String("--file"),
String("src/main.py"),
String("new_name=renamed_symbol"),
String("offset=4"),
],
"command": Object {
"domain": String("act"),
"operation": String("refactor"),
},
},
],
}
Loading
Loading