-
Notifications
You must be signed in to change notification settings - Fork 0
Replace custom build tool clock with mockable Clock (#194) #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| //! Monotonic clocks for measuring build tool execution duration. | ||
|
|
||
| use std::time::Instant; | ||
|
|
||
| /// Clock abstraction for monotonic elapsed-time measurements. | ||
| pub trait MonotonicClock: Send + Sync { | ||
| /// Returns the current monotonic instant. | ||
| fn now(&self) -> Instant; | ||
| } | ||
|
|
||
| /// Monotonic clock backed by [`Instant::now`]. | ||
| pub struct StdMonotonicClock; | ||
|
|
||
| impl MonotonicClock for StdMonotonicClock { | ||
| fn now(&self) -> Instant { | ||
| Instant::now() | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| pub struct FixedMonotonicClock { | ||
| instants: std::sync::Mutex<std::collections::VecDeque<Instant>>, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl FixedMonotonicClock { | ||
| pub fn with_elapsed(elapsed: std::time::Duration) -> Self { | ||
| let start = Instant::now(); | ||
| Self { | ||
| instants: std::sync::Mutex::new(std::collections::VecDeque::from([ | ||
| start, | ||
| start + elapsed, | ||
| ])), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl MonotonicClock for FixedMonotonicClock { | ||
| fn now(&self) -> Instant { | ||
| self.instants | ||
| .lock() | ||
| .expect("fixed monotonic clock mutex should not be poisoned") | ||
| .pop_front() | ||
| .unwrap_or_else(Instant::now) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,29 +1,17 @@ | ||||||||||||||||||||
| //! Tests for the build software native-tool wrapper. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| use std::sync::{Arc, Mutex}; | ||||||||||||||||||||
| use std::time::Duration; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| use super::super::domain::SoftwareBuilderFuture; | ||||||||||||||||||||
| use super::clock::FixedMonotonicClock; | ||||||||||||||||||||
| use super::*; | ||||||||||||||||||||
| use insta::assert_snapshot; | ||||||||||||||||||||
| use rstest::rstest; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type AnalyzeResult = dyn Fn() -> Result<BuildRequirement, AgentToolError> + Send + Sync; | ||||||||||||||||||||
| type BuildResultFn = dyn Fn(&BuildRequirement) -> Result<BuildResult, AgentToolError> + Send + Sync; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| struct FixedClock { | ||||||||||||||||||||
| elapsed: std::time::Duration, | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| impl Clock for FixedClock { | ||||||||||||||||||||
| fn now(&self) -> std::time::Instant { | ||||||||||||||||||||
| std::time::Instant::now() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fn elapsed_since(&self, _start: std::time::Instant) -> std::time::Duration { | ||||||||||||||||||||
| self.elapsed | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| fn assert_invalid_parameters<T: std::fmt::Debug>(result: Result<T, ToolError>, expected_msg: &str) { | ||||||||||||||||||||
| match result.expect_err("expected invalid parameters error") { | ||||||||||||||||||||
| ToolError::InvalidParameters(msg) => assert_eq!(msg, expected_msg), | ||||||||||||||||||||
|
|
@@ -248,41 +236,38 @@ async fn execute_missing_description_returns_error() { | |||||||||||||||||||
| assert_invalid_parameters(result, "missing 'description'"); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| #[tokio::test] | ||||||||||||||||||||
| async fn execute_analyze_failure_returns_execution_failed() { | ||||||||||||||||||||
| let builder = FakeSoftwareBuilder::analyze_error("analysis exploded"); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new(Arc::new(builder)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| async fn execute_failure_returns_execution_failed( | ||||||||||||||||||||
| builder: Arc<dyn SoftwareBuilder>, | ||||||||||||||||||||
| expected_msg: &str, | ||||||||||||||||||||
| ) { | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new(builder); | ||||||||||||||||||||
| let result = tool | ||||||||||||||||||||
| .execute( | ||||||||||||||||||||
| serde_json::json!({ | ||||||||||||||||||||
| "description": "build a test tool", | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| serde_json::json!({ "description": "build a test tool" }), | ||||||||||||||||||||
| &JobContext::default(), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .await; | ||||||||||||||||||||
| assert_execution_failed(result, expected_msg); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| assert_execution_failed( | ||||||||||||||||||||
| result, | ||||||||||||||||||||
| #[tokio::test] | ||||||||||||||||||||
| async fn execute_analyze_failure_returns_execution_failed() { | ||||||||||||||||||||
| let builder = FakeSoftwareBuilder::analyze_error("analysis exploded"); | ||||||||||||||||||||
| execute_failure_returns_execution_failed( | ||||||||||||||||||||
| Arc::new(builder), | ||||||||||||||||||||
| "Analysis failed: Tool builder failed: analysis exploded", | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .await; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| #[tokio::test] | ||||||||||||||||||||
| async fn execute_build_failure_returns_execution_failed() { | ||||||||||||||||||||
| let builder = FakeSoftwareBuilder::build_error(test_requirement(), "build exploded"); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new(Arc::new(builder)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let result = tool | ||||||||||||||||||||
| .execute( | ||||||||||||||||||||
| serde_json::json!({ | ||||||||||||||||||||
| "description": "build a test tool", | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| &JobContext::default(), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .await; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| assert_execution_failed(result, "Build failed: Tool builder failed: build exploded"); | ||||||||||||||||||||
| execute_failure_returns_execution_failed( | ||||||||||||||||||||
| Arc::new(builder), | ||||||||||||||||||||
| "Build failed: Tool builder failed: build exploded", | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .await; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| #[rstest] | ||||||||||||||||||||
|
|
@@ -314,7 +299,10 @@ async fn execute_valid_params_returns_success_output() { | |||||||||||||||||||
| let requirement = test_requirement(); | ||||||||||||||||||||
| let build_result = test_build_result(requirement.clone()); | ||||||||||||||||||||
| let builder = FakeSoftwareBuilder::success(requirement, build_result); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new(Arc::new(builder)); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new_with_clock( | ||||||||||||||||||||
| Arc::new(builder), | ||||||||||||||||||||
| Arc::new(FixedMonotonicClock::with_elapsed(Duration::from_millis(1))), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let output = tool | ||||||||||||||||||||
| .execute( | ||||||||||||||||||||
|
|
@@ -326,6 +314,10 @@ async fn execute_valid_params_returns_success_output() { | |||||||||||||||||||
| .await | ||||||||||||||||||||
| .expect("expected execute to return successful output"); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| assert!( | ||||||||||||||||||||
| output.duration > Duration::ZERO, | ||||||||||||||||||||
| "duration must be positive" | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
Comment on lines
+317
to
+320
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Assert the exact injected duration rather than only checking positivity. The test injects The snapshot test at lines 351–355 already demonstrates the correct pattern by asserting exact equality. ♻️ Proposed fix- assert!(
- output.duration > Duration::ZERO,
- "duration must be positive"
- );
+ assert_eq!(
+ output.duration,
+ Duration::from_millis(1),
+ "duration must reflect the clock seam, not wall time"
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| assert_eq!(output.result["success"], true); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let captured = execute_capturing_requirement(serde_json::json!({ | ||||||||||||||||||||
|
|
@@ -341,10 +333,10 @@ async fn execute_success_output_matches_snapshot() { | |||||||||||||||||||
| let requirement = test_requirement(); | ||||||||||||||||||||
| let build_result = test_build_result(requirement.clone()); | ||||||||||||||||||||
| let builder = FakeSoftwareBuilder::success(requirement, build_result); | ||||||||||||||||||||
| let clock = Arc::new(FixedClock { | ||||||||||||||||||||
| elapsed: std::time::Duration::from_millis(42), | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new_with_clock(Arc::new(builder), clock); | ||||||||||||||||||||
| let tool = BuildSoftwareTool::new_with_clock( | ||||||||||||||||||||
| Arc::new(builder), | ||||||||||||||||||||
| Arc::new(FixedMonotonicClock::with_elapsed(Duration::from_millis(42))), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let output = tool | ||||||||||||||||||||
| .execute( | ||||||||||||||||||||
|
|
@@ -356,7 +348,11 @@ async fn execute_success_output_matches_snapshot() { | |||||||||||||||||||
| .await | ||||||||||||||||||||
| .expect("expected execute to return successful output"); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| assert_eq!(output.duration, std::time::Duration::from_millis(42)); | ||||||||||||||||||||
| assert_eq!( | ||||||||||||||||||||
| output.duration, | ||||||||||||||||||||
| Duration::from_millis(42), | ||||||||||||||||||||
| "duration must reflect the clock seam, not wall time" | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| assert_eq!(output.cost, None); | ||||||||||||||||||||
| assert_eq!(output.raw, None); | ||||||||||||||||||||
| assert_snapshot!( | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replace the silent
Instant::nowfallback with a panic.The
unwrap_or_else(Instant::now)fallback defeats test determinism. If a test under-provisions the instant queue,now()silently falls back to wall time, allowing non-deterministic test behaviour that could intermittently pass or fail based on actual elapsed time.Panic with a descriptive message so misconfigured tests fail immediately and obviously.
🛡️ Proposed fix
fn now(&self) -> Instant { self.instants .lock() .expect("fixed monotonic clock mutex should not be poisoned") .pop_front() - .unwrap_or_else(Instant::now) + .expect("fixed monotonic clock exhausted: test must seed enough instants via with_elapsed") }📝 Committable suggestion
🤖 Prompt for AI Agents