返回 CodeWhale
testing.rs
根目录 / crates / workflow-js / src / testing.rs
1 //! Test support: a scriptable in-memory [`WorkflowDriver`].
2 //!
3 //! [`FakeDriver`] records every [`TaskRequest`] and [`ProgressEvent`] it
4 //! receives, answers spawns from substring-matched reply rules (with optional
5 //! delays for ordering tests), and counts `cancel_all` calls. It exists so
6 //! this crate — and the tui wiring that implements the real driver — can be
7 //! exercised without spawning a single real subagent.
8
9 use std::sync::Mutex;
10 use std::sync::atomic::{AtomicUsize, Ordering};
11 use std::time::Duration;
12
13 use async_trait::async_trait;
14 use tokio::sync::oneshot;
15
16 use crate::driver::{
17 BudgetSnapshot, ProgressEvent, SpawnedTask, TaskCompletion, TaskRequest, WorkflowDriver,
18 };
19 use crate::error::DriverError;
20
21 /// How the fake answers a matched spawn.
22 #[derive(Debug, Clone)]
23 pub enum FakeReply {
24 /// Resolve with this full result text.
25 Complete(String),
26 /// Resolve as a failed subagent.
27 Fail(String),
28 /// Resolve as cancelled.
29 Cancelled,
30 /// Resolve as budget-exhausted mid-flight.
31 BudgetExhausted(String),
32 /// Refuse admission: `spawn_task` returns [`DriverError::Rejected`].
33 Reject(String),
34 /// The driver seam is gone: `spawn_task` returns
35 /// [`DriverError::Unavailable`].
36 Unavailable(String),
37 /// Admit the task, then drop the completion sender without ever sending a
38 /// terminal outcome — the "driver dropped the completion channel" path.
39 DropCompletion,
40 /// Admit the task but never complete it (for cancellation tests). The
41 /// completion sender is held so the channel stays open.
42 Never,
43 }
44
45 #[derive(Debug)]
46 struct ReplyRule {
47 needle: String,
48 delay: Option<Duration>,
49 reply: FakeReply,
50 }
51
52 #[derive(Debug, Default)]
53 struct Inner {
54 rules: Vec<ReplyRule>,
55 requests: Vec<TaskRequest>,
56 events: Vec<ProgressEvent>,
57 budget: BudgetSnapshot,
58 spend_per_task: u64,
59 next_id: u64,
60 held: Vec<oneshot::Sender<TaskCompletion>>,
61 }
62
63 /// In-memory [`WorkflowDriver`] with scripted replies.
64 ///
65 /// Unmatched spawns complete immediately with `done:<description>`. Rules are
66 /// matched by substring against the request description, first match wins.
67 #[derive(Debug, Default)]
68 pub struct FakeDriver {
69 inner: Mutex<Inner>,
70 cancel_calls: AtomicUsize,
71 }
72
73 impl FakeDriver {
74 /// A fake with no rules, no budget ceiling, and echo replies.
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 /// Add a reply rule: requests whose description contains `needle` get
80 /// `reply` immediately.
81 pub fn on(&self, needle: &str, reply: FakeReply) {
82 self.on_with_delay_opt(needle, reply, None);
83 }
84
85 /// Like [`FakeDriver::on`], but the completion is delivered after `delay`
86 /// (the spawn itself still returns immediately).
87 pub fn on_with_delay(&self, needle: &str, reply: FakeReply, delay: Duration) {
88 self.on_with_delay_opt(needle, reply, Some(delay));
89 }
90
91 fn on_with_delay_opt(&self, needle: &str, reply: FakeReply, delay: Option<Duration>) {
92 self.lock().rules.push(ReplyRule {
93 needle: needle.to_string(),
94 delay,
95 reply,
96 });
97 }
98
99 /// Configure the budget pool: ceiling plus a fixed spend debited at each
100 /// spawn (simulating the driver-side reservation of design §5.3).
101 pub fn set_budget(&self, total: Option<u64>, spend_per_task: u64) {
102 let mut inner = self.lock();
103 inner.budget = BudgetSnapshot { total, spent: 0 };
104 inner.spend_per_task = spend_per_task;
105 }
106
107 /// Every request received so far, in spawn order.
108 pub fn requests(&self) -> Vec<TaskRequest> {
109 self.lock().requests.clone()
110 }
111
112 /// Descriptions of every request, in spawn order.
113 pub fn request_descriptions(&self) -> Vec<String> {
114 self.lock()
115 .requests
116 .iter()
117 .map(|request| request.description.clone())
118 .collect()
119 }
120
121 /// Number of admitted spawn calls.
122 pub fn spawn_count(&self) -> usize {
123 self.lock().requests.len()
124 }
125
126 /// Every progress event received so far, in emit order.
127 pub fn events(&self) -> Vec<ProgressEvent> {
128 self.lock().events.clone()
129 }
130
131 /// How many times `cancel_all` has been invoked.
132 pub fn cancel_all_calls(&self) -> usize {
133 self.cancel_calls.load(Ordering::SeqCst)
134 }
135
136 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
137 self.inner.lock().expect("FakeDriver mutex poisoned")
138 }
139 }
140
141 #[async_trait]
142 impl WorkflowDriver for FakeDriver {
143 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError> {
144 let (task_id, reply, delay) = {
145 let mut inner = self.lock();
146 let matched = inner
147 .rules
148 .iter()
149 .find(|rule| request.description.contains(&rule.needle))
150 .map(|rule| (rule.reply.clone(), rule.delay));
151 let (reply, delay) = matched.unwrap_or_else(|| {
152 (
153 FakeReply::Complete(format!("done:{}", request.description)),
154 None,
155 )
156 });
157 match reply {
158 FakeReply::Reject(message) => return Err(DriverError::Rejected(message)),
159 FakeReply::Unavailable(message) => {
160 return Err(DriverError::Unavailable(message));
161 }
162 _ => {}
163 }
164 inner.requests.push(request);
165 inner.budget.spent += inner.spend_per_task;
166 inner.next_id += 1;
167 (format!("agent_{:04}", inner.next_id), reply, delay)
168 };
169
170 let (tx, rx) = oneshot::channel();
171 match reply {
172 FakeReply::Never => self.lock().held.push(tx),
173 FakeReply::DropCompletion => drop(tx),
174 reply => {
175 let completion = match reply {
176 FakeReply::Complete(text) => TaskCompletion::Completed { text },
177 FakeReply::Fail(message) => TaskCompletion::Failed { message },
178 FakeReply::Cancelled => TaskCompletion::Cancelled,
179 FakeReply::BudgetExhausted(message) => {
180 TaskCompletion::BudgetExhausted { message }
181 }
182 FakeReply::Reject(_)
183 | FakeReply::Unavailable(_)
184 | FakeReply::Never
185 | FakeReply::DropCompletion => unreachable!("handled above"),
186 };
187 match delay {
188 None => {
189 let _ = tx.send(completion);
190 }
191 Some(delay) => {
192 tokio::spawn(async move {
193 tokio::time::sleep(delay).await;
194 let _ = tx.send(completion);
195 });
196 }
197 }
198 }
199 }
200 Ok(SpawnedTask {
201 task_id,
202 completion: rx,
203 })
204 }
205
206 fn cancel_all(&self) {
207 self.cancel_calls.fetch_add(1, Ordering::SeqCst);
208 }
209
210 fn budget(&self) -> BudgetSnapshot {
211 self.lock().budget
212 }
213
214 fn progress(&self, event: ProgressEvent) {
215 self.lock().events.push(event);
216 }
217 }
218
218 lines RUST