app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
//! Test harness: boots the REAL compiled binary and drives it over HTTP,
//! exactly as ab does.
//!
//! Rules this harness exists to enforce:
//! * no test may assert by reading source files as strings — every claim is
//! made against a running process;
//! * everything the bridge writes goes to a temp dir that is deleted
//! afterwards (`SWX_SCRATCH_DIR`), so a test can check what landed on disk
//! and nothing leaks into the developer's real cache;
//! * every response is checked for the invariants (200, parses as JSON,
//! carries a boolean `success`) on EVERY call, so those cannot rot;
//! * every command that goes over the wire is RECORDED, which is what makes
//! the verb-coverage gate honest — it measures calls actually made, not a
//! hand-maintained list.
#![allow(dead_code)]
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use swx_bridge::httpc;
pub const BIN: &str = env!("CARGO_BIN_EXE_swx-bridge");
/// A temp directory that cleans itself up — unless it is a BORROWED view of
/// one somebody else owns.
///
/// The distinction matters for exactly one kind of test: "a DIFFERENT server
/// process reads back what this one persisted". Two owners of the same path
/// means the first drop deletes the state the second was meant to read, and the
/// test then proves the opposite of what it claims.
pub struct TempDir(pub PathBuf, bool);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("swx-test-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
TempDir(dir, true)
}
/// A view of a directory owned elsewhere: usable, never deleted.
pub fn borrowed(dir: &std::path::Path) -> TempDir {
TempDir(dir.to_path_buf(), false)
}
pub fn join(&self, name: &str) -> PathBuf {
self.0.join(name)
}
pub fn path(&self) -> String {
self.0.to_string_lossy().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
if self.1 {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
/// An OS-assigned free port. (The listener closes before the bridge binds it —
/// the same small race the Python harness lived with, and the bridge's own
/// single-instance guard turns a collision into a clean failure, not a hang.)
pub fn free_port() -> u16 {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
l.local_addr().expect("addr").port()
}
pub struct Bridge {
child: Child,
pub port: u16,
pub scratch: TempDir,
log_path: PathBuf,
_log_dir: TempDir,
called: Mutex<BTreeSet<String>>,
/// Verbs that have been CALLED and then ASSERTED ON. The coverage gate uses
/// this rather than `called`, because "the suite POSTed swx_kill once and
/// looked at nothing" is not coverage — it is a line in a log that makes a
/// gate go green.
asserted: Mutex<BTreeSet<String>>,
/// The verb the next `check!` will be attributed to.
last_verb: Mutex<Option<String>>,
call_count: std::sync::atomic::AtomicUsize,
}
impl Bridge {
pub fn start() -> Bridge {
Bridge::start_with(&[])
}
/// Boot the real server in mock mode on a free port, with its own scratch
/// dir, and wait until /status answers.
pub fn start_with(extra_env: &[(&str, &str)]) -> Bridge {
Bridge::start_in(TempDir::new("scratch"), extra_env)
}
/// Boot with a scratch dir the caller owns — the only way to prove that
/// something the bridge PERSISTS (the ownership registry, the route that
/// worked) is read back by a DIFFERENT server process. A test that reboots
/// into a fresh temp dir proves the opposite of what it claims to.
pub fn start_in(scratch: TempDir, extra_env: &[(&str, &str)]) -> Bridge {
// THE PORT RACE, AND WHY THIS LOOPS.
//
// `free_port()` binds a listener, reads the port the OS assigned, and
// CLOSES it — so between that close and the child's bind, another test
// binary in the parallel suite can be handed the same port and take it.
// The loser prints "port already in use — exiting cleanly" and dies,
// which is the BRIDGE BEHAVING CORRECTLY: it refuses to steal a port
// somebody else owns. The defect is in this harness's allocation, not in
// the product, and it made
// `the_type_mismatch_that_named_the_argument_reaches_the_caller` fail
// about one run in five under the full suite.
//
// So a collision on THAT EXACT CAUSE takes the next port. Every other
// startup failure still panics with the log, because a harness that
// retries everything hides real breakage.
// OWNERSHIP OF THE SCRATCH DIR TRAVELS WITH EACH ATTEMPT and comes back
// on failure. Handing the inner call a BORROWED view instead left the
// owned `TempDir` to drop at the end of this function — which DELETES the
// directory the bridge is using. Three launch tests caught that
// immediately, which is the system working.
let mut scratch = scratch;
for attempt in 0..8 {
match Self::start_on_a_free_port(scratch, extra_env) {
Ok(b) => return b,
Err((returned, log)) if log.contains("already in use") && attempt < 7 => {
scratch = returned;
continue;
}
Err((_, log)) => panic!(
"the bridge did not come up on attempt {}: {log}",
attempt + 1
),
}
}
unreachable!("the loop above either returns or panics")
}
fn start_on_a_free_port(
scratch: TempDir,
extra_env: &[(&str, &str)],
) -> Result<Bridge, (TempDir, String)> {
let port = free_port();
let log_dir = TempDir::new("log");
let log_path = log_dir.join("bridge.log");
let log = std::fs::File::create(&log_path).expect("create log");
let log_err = log.try_clone().expect("clone log handle");
let mut cmd = Command::new(BIN);
cmd.arg("--port")
.arg(port.to_string())
.env("SWX_MOCK", "1")
.env("ADOM_BIND_HOST", "127.0.0.1")
.env("SWX_SCRATCH_DIR", scratch.path())
// The fresh-attach settle (0.11.3) is 20 s in production; a test that
// is ABOUT it sets its own.
.env("SWX_READY_SETTLE_SECS", "0")
// ...and so is the effect re-read of a fresh session (1.5 s).
.env("SWX_EFFECT_RECHECK_MS", "0")
// ab injects these; absent here on purpose so the callback path is
// exercised in its "ab unreachable" shape.
.env_remove("ADOM_DIRECT_API_URL")
.env_remove("ADOM_BRIDGE_TOKEN")
.stdout(Stdio::from(log))
.stderr(Stdio::from(log_err));
for (k, v) in extra_env {
cmd.env(k, v);
}
let child = cmd.spawn().unwrap_or_else(|e| panic!("spawn {BIN}: {e}"));
let bridge = Bridge {
child,
port,
scratch,
log_path,
_log_dir: log_dir,
called: Mutex::new(BTreeSet::new()),
asserted: Mutex::new(BTreeSet::new()),
last_verb: Mutex::new(None),
call_count: std::sync::atomic::AtomicUsize::new(0),
};
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Ok(r) = httpc::get(&bridge.url("/status"), Duration::from_secs(2)) {
if r.status == 200 {
return Ok(bridge);
}
}
// THE CHILD IS GONE. Say so from its log rather than waiting out the
// deadline — the port collision exits in milliseconds, and the caller
// decides whether that log means "try another port" or "fail".
if bridge.is_gone() || Instant::now() > deadline {
let mut bridge = bridge;
let log = bridge.log();
// Hand the directory back so the next attempt uses the SAME one:
// the caller's test wrote its fixtures there. The failed Bridge is
// then dropped holding only a BORROWED view, so its Drop kills the
// child without deleting the directory.
let scratch = bridge.take_scratch();
return Err((scratch, log));
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Move the scratch directory OUT, leaving a borrowed view behind — so this
/// Bridge's `Drop` stops owning it and the caller can hand it to another
/// attempt.
fn take_scratch(&mut self) -> TempDir {
let p = self.scratch.0.clone();
std::mem::replace(&mut self.scratch, TempDir::borrowed(&p))
}
/// Has the child gone? Non-blocking. `try_wait` needs `&mut`, and the child
/// is behind an immutable borrow here, so this asks the OS about the pid —
/// which is also how the rest of this project answers liveness questions.
fn is_gone(&self) -> bool {
!std::path::Path::new(&format!("/proc/{}", self.child.id())).exists()
}
pub fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{path}", self.port)
}
pub fn get(&self, path: &str) -> (u16, Value) {
let r = httpc::get(&self.url(path), Duration::from_secs(10))
.unwrap_or_else(|e| panic!("GET {path}: {e}"));
let body = r.json().unwrap_or_else(|e| panic!("GET {path} body: {e}"));
(r.status, body)
}
/// POST a verb the way ab does, recording it for the coverage gate and
/// checking the response invariants that every verb must satisfy.
pub fn call(&self, command: &str, args: Value) -> Value {
self.call_with_headers(command, args, &[])
}
pub fn call_with_headers(
&self,
command: &str,
args: Value,
headers: &[(String, String)],
) -> Value {
self.called.lock().unwrap().insert(command.to_string());
*self.last_verb.lock().unwrap() = Some(command.to_string());
self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let body = json!({"command": command, "args": args}).to_string();
// 120s, not 30: `swx_stop` WAITS for the exit it asked for, up to the
// rung's own budget (30s for rung (a)), because a real `ExitApp` returns
// before the process is gone. A client that gives up before the verb does
// proves nothing about the verb.
let r = httpc::post_json(&self.url("/command"), headers, &body, Duration::from_secs(120))
.unwrap_or_else(|e| panic!("POST {command}: {e}\nlog:\n{}", self.log()));
assert_eq!(
r.status,
200,
"{command} must answer 200 on a well-formed request, got {} / {}",
r.status,
r.body
);
let value: Value = r
.json()
.unwrap_or_else(|e| panic!("{command} response is not JSON: {e}\nlog:\n{}", self.log()));
assert!(
value.get("success").map(|s| s.is_boolean()).unwrap_or(false),
"{command} response must carry a boolean `success`: {value}"
);
value
}
/// A raw body, for malformed-input tests.
pub fn post_raw(&self, body: &str) -> (u16, String) {
let r = httpc::post_json(&self.url("/command"), &[], body, Duration::from_secs(10))
.expect("post raw");
(r.status, r.body)
}
/// How many verb POSTs this harness has made — the yardstick for the
/// "one log line per verb" volume check.
pub fn call_count(&self) -> usize {
self.call_count.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn covered(&self) -> BTreeSet<String> {
self.called.lock().unwrap().clone()
}
/// Called by `check!`: credit the assertion to the verb its NAME mentions,
/// falling back to the most recent call.
///
/// Crediting the last call unconditionally was foolable in the other
/// direction: a check named "swx_export_step writes step" would be credited
/// to whatever happened to be POSTed last, so the gate could be satisfied by
/// assertions that were never about the verb they covered. Binding to the
/// named verb makes the credit mean what it says.
pub fn note_assertion(&self, name: &str) {
let named = name
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.find(|t| t.starts_with("swx_") && t.len() > 4)
.map(|t| t.to_string());
// A check may only credit a verb it NAMES if that verb has actually been
// called — otherwise a check named after a verb nobody ever POSTed would
// satisfy the gate on its own. The `covered` gate backstops this, but
// the two should not be able to disagree.
let credit = match named {
Some(v) if self.called.lock().unwrap().contains(&v) => Some(v),
_ => self.last_verb.lock().unwrap().clone(),
};
if let Some(v) = credit {
self.asserted.lock().unwrap().insert(v);
}
}
/// Verbs with at least one assertion made against their response.
pub fn asserted(&self) -> BTreeSet<String> {
self.asserted.lock().unwrap().clone()
}
pub fn log(&self) -> String {
std::fs::read_to_string(&self.log_path).unwrap_or_default()
}
pub fn pid(&self) -> u32 {
self.child.id()
}
}
impl Drop for Bridge {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// A stand-in for ab's direct API, so the HALF OF THE BRIDGE THAT NEVER SPEAKS
/// TO THE COM HOST can be tested at all.
///
/// `swx_kill`, `swx_close_window`, `swx_dismiss_blocking_dialogs`,
/// `swx_screenshot` and `swx_set_driving_badge` all go out through ab. With no
/// ab reachable they only ever exercised their "ab is unreachable" branch — so
/// a guard on that side could be removed and every test would stay green, which
/// is exactly how round 7's two Criticals survived six reviews.
///
/// It records every request, so a test can assert what was DISPATCHED rather
/// than only what was returned — "the refusal returned an error" and "nothing
/// was sent" are different claims, and the second is the one that matters.
pub struct AbStub {
pub url: String,
calls: std::sync::Arc<Mutex<Vec<Value>>>,
windows: std::sync::Arc<Mutex<Value>>,
/// Per-ab-verb replies, for the verbs whose ANSWER is what the bridge
/// branches on. `desktop_find_control` is the gate between "a window looked
/// like a crash reporter" and "we clicked a button on it", so a stub that
/// answered every verb with a window list could not tell those apart.
replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
/// One-shot triggers: when `.0` arrives, `.1` answers `.2` from then on —
/// swapped BEFORE `.0` is answered, so a test can model "the page changed as
/// a result of this call" without racing the bridge.
triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>>,
/// Replies chosen by the REQUEST'S ARGS (0.9.23): `desktop_find_control`
/// answers by the name / automation id asked for. First match wins; every
/// key of the rule's `when` must equal the request's arg.
rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>>,
_thread: std::thread::JoinHandle<()>,
}
impl AbStub {
/// `windows` is what `desktop_list_windows` reports.
pub fn start(windows: Value) -> AbStub {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ab stub");
let port = listener.local_addr().unwrap().port();
let calls = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = std::sync::Arc::clone(&calls);
let windows = std::sync::Arc::new(Mutex::new(windows));
let wins = std::sync::Arc::clone(&windows);
let replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>> =
std::sync::Arc::new(Mutex::new(std::collections::BTreeMap::new()));
let reps = std::sync::Arc::clone(&replies);
let triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
let trig = std::sync::Arc::clone(&triggers);
let rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
let rls = std::sync::Arc::clone(&rules);
let thread = std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { break };
use std::io::{Read, Write};
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let mut command = String::new();
let mut req_args = Value::Null;
// Read headers, then exactly Content-Length bytes.
loop {
let n = match s.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf).to_string();
if let Some(hdr_end) = text.find("\r\n\r\n") {
let len: usize = text
.to_lowercase()
.split("content-length:")
.nth(1)
.and_then(|t| t.split(['\r', '\n']).next())
.and_then(|t| t.trim().parse().ok())
.unwrap_or(0);
if buf.len() >= hdr_end + 4 + len {
let body = &text[hdr_end + 4..];
if let Ok(v) = serde_json::from_str::<Value>(body) {
command = v
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
req_args = v.get("args").cloned().unwrap_or(Value::Null);
sink.lock().unwrap().push(v);
}
break;
}
}
}
{
let mut t = trig.lock().unwrap();
let fired: Vec<(String, String, Value)> = t.iter().filter(|(c, _, _)| *c == command).cloned().collect();
t.retain(|(c, _, _)| *c != command);
for (_, target, payload) in fired {
reps.lock().unwrap().insert(target, payload);
}
}
let ruled = rls.lock().unwrap().iter().find(|(c, when, _)| {
*c == command && when.as_object().map(|w| w.iter().all(|(k, v)| req_args.get(k) == Some(v))).unwrap_or(false)
}).map(|(_, _, p)| p.clone());
let payload = match ruled.as_ref().or(reps.lock().unwrap().get(&command)) {
Some(custom) => custom.clone(),
None => json!({
"success": true,
"status": "ok",
"exitCode": 0,
"stdout": "SUCCESS",
"output": json!({"data": {"windows": wins.lock().unwrap().clone()}}).to_string(),
}),
}
.to_string();
let _ = write!(
s,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
);
}
});
AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, triggers, rules, _thread: thread }
}
/// When `trigger` arrives (once), `target` answers `payload` from then on.
pub fn on_call_set(&self, trigger: &str, target: &str, payload: Value) {
self.triggers.lock().unwrap().push((trigger.to_string(), target.to_string(), payload));
}
/// Answer `command` with `payload` when the request's args include `when`.
pub fn when(&self, command: &str, when: Value, payload: Value) {
self.rules.lock().unwrap().push((command.to_string(), when, payload));
}
/// Drop every args-matched rule for `command`.
pub fn clear_rules(&self, command: &str) {
self.rules.lock().unwrap().retain(|(c, _, _)| c != command);
}
/// Answer one ab verb with a specific payload.
pub fn set_response(&self, command: &str, payload: Value) {
self.replies.lock().unwrap().insert(command.to_string(), payload);
}
/// Replace the window list. Needed because attribution matches on OWNER PID,
/// which is only knowable once the bridge is up and has answered
/// `swx_get_app_state`.
pub fn set_windows(&self, windows: Value) {
*self.windows.lock().unwrap() = windows;
}
/// Every request ab received, in order.
pub fn calls(&self) -> Vec<Value> {
self.calls.lock().unwrap().clone()
}
/// Requests for one ab verb.
pub fn calls_to(&self, command: &str) -> Vec<Value> {
self.calls()
.into_iter()
.filter(|c| c.get("command").and_then(|v| v.as_str()) == Some(command))
.collect()
}
/// Did anything DESTRUCTIVE get dispatched?
pub fn destructive_calls(&self) -> Vec<Value> {
self.calls()
.into_iter()
.filter(|c| {
matches!(
c.get("command").and_then(|v| v.as_str()),
Some("shell_execute") | Some("desktop_ui_window")
)
})
.collect()
}
}
/// One JSON protocol line from a com-host child, skipping event lines.
pub fn read_line_json(stdout: &mut std::io::BufReader<std::process::ChildStdout>) -> Value {
loop {
let mut line = String::new();
std::io::BufRead::read_line(stdout, &mut line).expect("read com-host line");
if line.is_empty() {
return Value::Null;
}
let v: Value = serde_json::from_str(line.trim()).unwrap_or(Value::Null);
if v.get("event").is_none() && !v.is_null() {
return v;
}
}
}
/// bridge.json, as shipped.
pub fn manifest() -> Value {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bridge.json");
let text = std::fs::read_to_string(&path).expect("read bridge.json");
serde_json::from_str(&text).expect("bridge.json must be valid JSON")
}
pub fn manifest_verbs() -> BTreeSet<String> {
manifest()["verbs"]
.as_array()
.expect("bridge.json verbs[]")
.iter()
.map(|v| v.as_str().expect("verb name").to_string())
.collect()
}
/// Assert `cond`, printing the response (and the server log) when it fails —
/// a failing test should never send anyone hunting for context.
#[macro_export]
macro_rules! check {
($bridge:expr, $name:expr, $cond:expr, $ctx:expr) => {
// Credit the assertion to the verb its NAME mentions (else the most
// recent call), so the coverage gate requires that every shipped verb
// was actually LOOKED AT — by a check that is about it.
$bridge.note_assertion($name);
assert!($cond, "{}: {}\n--- server log ---\n{}", $name, $ctx, $bridge.log());
};
}