Skip to content

Commit 4f0dbd7

Browse files
committed
test(coverage): address PR review — tighten assertions, fix flaky test, add env-var RAII guard
Inline review fixes: - migration/ops.rs: migrate_openclaw_returns_error_for_missing_source_workspace now requires Err() (the underlying helper bails when the source dir doesn't exist) and asserts a non-empty error message. - text_input/ops.rs: replace tautological `!inserted || inserted` in insert_text_surfaces_accessibility_failure_as_inserted_false with the real contract: require Ok(..) and pin `inserted`↔`error` mutual exclusion. Headless runs legitimately see inserted=false, so the assertion still holds while catching regressions in either branch. - update/scheduler.rs: remove tick_runs_without_panicking_when_event_bus_is_uninitialised — it hit real api.github.com HTTPS and was flaky under offline CI / rate limits. Replace with a comment documenting the decision and the integration-test path for exercising tick(). Nitpick fixes: - security/ops.rs: extend security_policy_info_matches_default_policy_values with assertions for autonomy and allowed_commands so the full default shape is pinned. - voice/postprocess.rs: tighten ready_llm_with_whitespace_only_context_never_embeds_header from `assert_eq!(result.trim(), "raw text")` to exact equality (cleanup_transcription trims internally). The pre-existing doc comment on with_ready_llm plus the in-module block comment at lines 255-268 already document the LOCAL_AI_TEST_MUTEX contract and the deliberate non-use in permissive tests — no new docs needed. - webhooks/ops.rs: list_tunnels_hits_webhooks_core_endpoint_and_returns_payload now asserts the inbound Authorization header equals `Bearer test-session-token`; get_tunnel_encodes_id_in_path uses an id full of reserved URL chars so the test actually verifies percent-encoding rather than just trimming. - workspace/ops.rs: introduce a WorkspaceEnvGuard RAII helper; replace all six unsafe set_var/remove_var pairs in the init_workspace tests so OPENHUMAN_WORKSPACE is cleared on panic too. Contract is documented inline: guard requires the caller to hold ENV_LOCK. Refs tinyhumansai#530.
1 parent 0b0f3a8 commit 4f0dbd7

7 files changed

Lines changed: 124 additions & 74 deletions

File tree

src/openhuman/migration/ops.rs

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,18 @@ mod tests {
5757
#[tokio::test]
5858
async fn migrate_openclaw_returns_error_for_missing_source_workspace() {
5959
// Pointing at a non-existent source directory must surface as
60-
// an Err from the wrapper (not a panic / ok), so the JSON-RPC
61-
// adapter can return the error to the caller.
60+
// an Err from the wrapper (the underlying `migrate_openclaw_memory`
61+
// bails with "OpenClaw workspace not found at ..."), so the
62+
// JSON-RPC adapter can return the error to the caller.
6263
let tmp = TempDir::new().unwrap();
6364
let config = test_config(&tmp);
6465
let missing = tmp.path().join("does-not-exist").join("nested");
65-
let result = migrate_openclaw(&config, Some(missing), false).await;
66-
// Either an Err OR an Ok with a non-success report is
67-
// acceptable here — we just pin the no-panic, deterministic-
68-
// shape contract.
69-
match result {
70-
Ok(outcome) => {
71-
// If the migration helper decides "nothing to do" for
72-
// a missing source and returns Ok, we still expect the
73-
// canonical log line.
74-
assert!(outcome
75-
.logs
76-
.iter()
77-
.any(|l| l.contains("migration completed")));
78-
}
79-
Err(e) => assert!(!e.is_empty()),
80-
}
66+
let err = migrate_openclaw(&config, Some(missing), false)
67+
.await
68+
.expect_err("missing source workspace must surface as Err");
69+
assert!(
70+
!err.is_empty(),
71+
"error string must be non-empty so the RPC caller sees a reason"
72+
);
8173
}
8274
}

src/openhuman/security/ops.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ mod tests {
5151
fn security_policy_info_matches_default_policy_values() {
5252
let outcome = security_policy_info();
5353
let default = SecurityPolicy::default();
54+
assert_eq!(outcome.value["autonomy"], json!(default.autonomy));
55+
assert_eq!(
56+
outcome.value["allowed_commands"],
57+
json!(default.allowed_commands)
58+
);
5459
assert_eq!(
5560
outcome.value["max_actions_per_hour"],
5661
json!(default.max_actions_per_hour)

src/openhuman/text_input/ops.rs

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -273,30 +273,43 @@ mod tests {
273273
#[tokio::test]
274274
async fn insert_text_surfaces_accessibility_failure_as_inserted_false() {
275275
// A non-empty payload bypasses the guard and reaches the
276-
// `accessibility::apply_text_to_focused_field` call. In a
277-
// headless test there's no focused field, so the OS-side call
278-
// errors and the wrapper packs the message into the result
279-
// struct rather than propagating it as Err.
280-
let result = insert_text(InsertTextParams {
276+
// `accessibility::apply_text_to_focused_field` call. The contract
277+
// of `insert_text` is: any platform failure is wrapped in
278+
// `InsertTextResult { inserted: false, error: Some(..) }` and
279+
// returned as `Ok` — never propagated as `Err` — so the JSON-RPC
280+
// caller always gets a structured result. We pin that contract.
281+
//
282+
// On a host with a focused text field `inserted` can legitimately
283+
// be `true`; in a headless CI runner it will be `false`. Either
284+
// way, `inserted` and `error` must be mutually exclusive.
285+
let r = insert_text(InsertTextParams {
281286
text: "hello".into(),
282287
// Keep validation flags off so the test only exercises the
283-
// `apply_text_to_focused_field` error path; turning them on
284-
// would route through `validate_focused_target` first which
285-
// has its own OS-specific behaviour.
288+
// `apply_text_to_focused_field` path; turning them on would
289+
// route through `validate_focused_target` first which has its
290+
// own OS-specific behaviour.
286291
validate_focus: None,
287292
expected_app: None,
288293
expected_role: None,
289294
})
290-
.await;
291-
// Either Ok(..) (error wrapped in result) or Err(..) — both
292-
// shapes are acceptable proof we reached the platform call
293-
// without panicking.
294-
match result {
295-
Ok(r) => assert!(
296-
!r.value.inserted || r.value.inserted,
297-
"insertion result must be a deterministic bool"
298-
),
299-
Err(e) => assert!(!e.is_empty()),
295+
.await
296+
.expect("insert_text must wrap platform failures as Ok(inserted=false)");
297+
298+
if r.value.inserted {
299+
assert!(
300+
r.value.error.is_none(),
301+
"inserted=true must not carry an error: {:?}",
302+
r.value.error
303+
);
304+
assert!(r.logs.iter().any(|l| l.contains("insert_text: ok")));
305+
} else {
306+
let err = r
307+
.value
308+
.error
309+
.as_deref()
310+
.expect("inserted=false must carry an error message");
311+
assert!(!err.is_empty(), "error message must be non-empty");
312+
assert!(r.logs.iter().any(|l| l.contains("insert_text: failed")));
300313
}
301314
}
302315
}

src/openhuman/update/scheduler.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,14 @@ mod tests {
107107
run(cfg).await;
108108
}
109109

110-
#[tokio::test]
111-
async fn tick_runs_without_panicking_when_event_bus_is_uninitialised() {
112-
// `tick` calls `update_core::check_available` (which may hit
113-
// GitHub) and then publishes a HealthChanged event. With no
114-
// event bus initialised in the test process, publish_global
115-
// must no-op. The HTTP call may succeed or fail depending on
116-
// network availability — either branch is acceptable as long
117-
// as `tick` returns cleanly.
118-
tick().await;
119-
}
110+
// NOTE: We deliberately do NOT unit-test `tick()` directly. It calls
111+
// `update_core::check_available()` which performs a real HTTPS request
112+
// to api.github.com — running that from the unit suite makes the test
113+
// flaky (offline CI runners, rate limits, DNS hiccups). Coverage of
114+
// the HTTP + JSON-parse path is better handled via an integration test
115+
// that uses an HTTP mock (e.g. `httpmock`) around a refactored
116+
// `check_available_with_url(base_url)`. For now the surrounding
117+
// properties are locked down by:
118+
// - `min_interval_is_at_least_ten_minutes` (rate-limit floor)
119+
// - `run_returns_immediately_when_disabled` (disabled short-circuit)
120120
}

src/openhuman/voice/postprocess.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ mod tests {
412412
!result.contains("Conversation context:"),
413413
"whitespace-only context must NOT be forwarded, got: {result}"
414414
);
415-
assert_eq!(result.trim(), "raw text");
415+
// Exact equality: `cleanup_transcription` trims the LLM response,
416+
// so either branch (LLM echo of the raw-only prompt, or the
417+
// short-circuit fallback) must return exactly "raw text".
418+
assert_eq!(result, "raw text");
416419
}
417420
}

src/openhuman/webhooks/ops.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ mod tests {
220220
};
221221
use axum::{
222222
extract::Path,
223+
http::HeaderMap,
223224
routing::{delete, get, patch, post},
224225
Json, Router,
225226
};
@@ -441,9 +442,24 @@ mod tests {
441442

442443
#[tokio::test]
443444
async fn list_tunnels_hits_webhooks_core_endpoint_and_returns_payload() {
445+
// Inspect the inbound Authorization header so we catch regressions
446+
// where the JWT stops being forwarded (or is sent with the wrong
447+
// scheme). `config_with_backend` stores `test-session-token`, so
448+
// the header must be `Bearer test-session-token`.
444449
let app = Router::new().route(
445450
"/webhooks/core",
446-
get(|| async { Json(json!({"tunnels": [{"id": "t-1"}]})) }),
451+
get(|headers: HeaderMap| async move {
452+
let auth = headers
453+
.get("authorization")
454+
.and_then(|v| v.to_str().ok())
455+
.unwrap_or("")
456+
.to_string();
457+
assert_eq!(
458+
auth, "Bearer test-session-token",
459+
"authorization header must forward the stored session token"
460+
);
461+
Json(json!({"tunnels": [{"id": "t-1"}]}))
462+
}),
447463
);
448464
let base = spawn_mock_backend(app).await;
449465
let tmp = TempDir::new().unwrap();
@@ -490,16 +506,26 @@ mod tests {
490506

491507
#[tokio::test]
492508
async fn get_tunnel_encodes_id_in_path() {
509+
// Use an id full of reserved URL characters so we actually verify
510+
// percent-encoding on the outbound path. axum's `Path` extractor
511+
// decodes before handing us the string, so the server must see
512+
// the trimmed, *decoded* form of the id.
493513
let app = Router::new().route(
494514
"/webhooks/core/{id}",
495515
get(|Path(id): Path<String>| async move { Json(json!({ "id": id })) }),
496516
);
497517
let base = spawn_mock_backend(app).await;
498518
let tmp = TempDir::new().unwrap();
499519
let config = config_with_backend(&tmp, base);
500-
let out = get_tunnel(&config, " abc-123 ").await.unwrap();
501-
// Server should see the trimmed id.
502-
assert_eq!(out.value["id"], json!("abc-123"));
520+
let raw_id = " abc:/?#[ ]@!$&'()*+,;=% ";
521+
let trimmed = raw_id.trim();
522+
let out = get_tunnel(&config, raw_id).await.unwrap();
523+
assert_eq!(
524+
out.value["id"],
525+
json!(trimmed),
526+
"server should receive the trimmed, decoded id — proves the client \
527+
percent-encoded reserved chars instead of sending them raw"
528+
);
503529
}
504530

505531
#[tokio::test]

src/openhuman/workspace/ops.rs

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,34 @@ mod tests {
103103
use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK;
104104
use tempfile::tempdir;
105105

106+
/// RAII guard for `OPENHUMAN_WORKSPACE`. Sets the env var on
107+
/// construction and clears it on drop so a panicking test doesn't
108+
/// leak the override into sibling tests. Must be constructed while
109+
/// holding `ENV_LOCK` — mutating process env vars concurrently is
110+
/// unsafe and the lock serialises every test in this module.
111+
struct WorkspaceEnvGuard;
112+
113+
impl WorkspaceEnvGuard {
114+
fn set(path: &std::path::Path) -> Self {
115+
// SAFETY: Caller holds `ENV_LOCK`, so no other thread in
116+
// this process is reading or mutating this env var.
117+
unsafe {
118+
std::env::set_var("OPENHUMAN_WORKSPACE", path);
119+
}
120+
Self
121+
}
122+
}
123+
124+
impl Drop for WorkspaceEnvGuard {
125+
fn drop(&mut self) {
126+
// SAFETY: Same contract as `set()` — `ENV_LOCK` is held for
127+
// the whole test, so no concurrent env access is possible.
128+
unsafe {
129+
std::env::remove_var("OPENHUMAN_WORKSPACE");
130+
}
131+
}
132+
}
133+
106134
// ── ensure_workspace_file ──────────────────────────────────────
107135

108136
#[test]
@@ -175,16 +203,11 @@ mod tests {
175203
async fn init_workspace_creates_dirs_and_files_in_fresh_workspace() {
176204
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
177205
let tmp = tempdir().unwrap();
178-
unsafe {
179-
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
180-
}
206+
let _env = WorkspaceEnvGuard::set(tmp.path());
181207

182-
let result = init_workspace(false).await;
183-
184-
unsafe {
185-
std::env::remove_var("OPENHUMAN_WORKSPACE");
186-
}
187-
let value = result.expect("init_workspace on empty temp should succeed");
208+
let value = init_workspace(false)
209+
.await
210+
.expect("init_workspace on empty temp should succeed");
188211

189212
let workspace_dir = value["result"]["workspace_dir"]
190213
.as_str()
@@ -221,20 +244,14 @@ mod tests {
221244
async fn init_workspace_reports_existing_entries_on_second_call_without_force() {
222245
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
223246
let tmp = tempdir().unwrap();
224-
unsafe {
225-
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
226-
}
247+
let _env = WorkspaceEnvGuard::set(tmp.path());
227248

228249
// First call populates the workspace.
229250
init_workspace(false).await.expect("first init ok");
230251
// Second call without force should report everything as existing
231252
// and nothing as created / overwritten.
232253
let value = init_workspace(false).await.expect("second init ok");
233254

234-
unsafe {
235-
std::env::remove_var("OPENHUMAN_WORKSPACE");
236-
}
237-
238255
let created = value["result"]["files"]["created"].as_array().unwrap();
239256
let overwritten = value["result"]["files"]["overwritten"].as_array().unwrap();
240257
let existing = value["result"]["files"]["existing"].as_array().unwrap();
@@ -261,9 +278,7 @@ mod tests {
261278
async fn init_workspace_with_force_overwrites_existing_bootstrap_files() {
262279
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
263280
let tmp = tempdir().unwrap();
264-
unsafe {
265-
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
266-
}
281+
let _env = WorkspaceEnvGuard::set(tmp.path());
267282

268283
let first = init_workspace(false).await.expect("initial init");
269284
// The config loader may place the workspace at a subpath of the
@@ -280,10 +295,6 @@ mod tests {
280295

281296
let value = init_workspace(true).await.expect("forced init");
282297

283-
unsafe {
284-
std::env::remove_var("OPENHUMAN_WORKSPACE");
285-
}
286-
287298
let overwritten: Vec<&str> = value["result"]["files"]["overwritten"]
288299
.as_array()
289300
.unwrap()

0 commit comments

Comments
 (0)