1use std::collections::{
2 HashMap,
3 HashSet,
4};
5use std::sync::Arc;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use futures::future::try_join_all;
10use pipedash_plugin_api::*;
11
12use crate::log_dialect::AzureDevOpsLogDialect;
13use crate::{
14 client,
15 config,
16 mapper,
17 metadata,
18 types,
19};
20
21pub struct AzureDevOpsPlugin {
22 metadata: PluginMetadata,
23 client: Option<client::AzureDevOpsClient>,
24 provider_id: Option<i64>,
25 organization: String,
26 config: HashMap<String, String>,
27 status_source: Option<StatusSource>,
28}
29
30impl Default for AzureDevOpsPlugin {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl AzureDevOpsPlugin {
37 pub fn new() -> Self {
38 Self {
39 metadata: metadata::create_metadata(),
40 client: None,
41 provider_id: None,
42 organization: String::new(),
43 config: HashMap::new(),
44 status_source: None,
45 }
46 }
47
48 fn client(&self) -> PluginResult<&client::AzureDevOpsClient> {
49 self.client
50 .as_ref()
51 .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
52 }
53
54 fn provider_id(&self) -> PluginResult<i64> {
55 self.provider_id
56 .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))
57 }
58
59 async fn fetch_project_pipelines(
60 &self, provider_id: i64, project: &str,
61 ) -> PluginResult<Vec<Pipeline>> {
62 let definitions = self.client()?.list_definitions(project).await?;
63 Ok(definitions
64 .iter()
65 .map(|definition| {
66 mapper::map_pipeline(provider_id, &self.organization, project, definition)
67 })
68 .collect())
69 }
70
71 async fn owned_build(
72 &self, project: &str, definition_id: i64, pipeline_id: &str, build_id: i64,
73 ) -> PluginResult<types::Build> {
74 let build = self.client()?.get_build(project, build_id).await?;
75 let belongs_to_pipeline = build
76 .definition
77 .as_ref()
78 .and_then(|definition| definition.id)
79 .is_none_or(|id| id == definition_id);
80 if !belongs_to_pipeline {
81 return Err(PluginError::PipelineNotFound(format!(
82 "Run #{build_id} does not belong to {pipeline_id}"
83 )));
84 }
85 Ok(build)
86 }
87
88 fn log_node_record_id(node_id: &str) -> PluginResult<&str> {
89 Some(node_id)
90 .filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-'))
91 .ok_or_else(|| {
92 PluginError::InvalidConfig(format!("Invalid Azure DevOps log node ID: {node_id}"))
93 })
94 }
95}
96
97#[async_trait]
98impl Plugin for AzureDevOpsPlugin {
99 fn metadata(&self) -> &PluginMetadata {
100 &self.metadata
101 }
102
103 fn status_source(&self) -> Option<&StatusSource> {
104 self.status_source.as_ref()
105 }
106
107 fn initialize(
108 &mut self, provider_id: i64, config: HashMap<String, String>,
109 http_client: Option<Arc<reqwest::Client>>,
110 ) -> PluginResult<()> {
111 let server_url = config::get_server_url(&config)?;
112 let token = config
113 .get("token")
114 .map(|token| token.trim())
115 .filter(|token| !token.is_empty())
116 .ok_or_else(|| {
117 PluginError::InvalidConfig("Missing Azure DevOps personal access token".to_string())
118 })?
119 .to_string();
120 let status_source = custom_status_source(&config)?;
121
122 let http_client = match http_client {
123 Some(http_client) => http_client,
124 None => Arc::new(
125 reqwest::Client::builder()
126 .use_rustls_tls()
127 .https_only(cfg!(target_os = "ios"))
128 .pool_max_idle_per_host(10)
129 .timeout(Duration::from_secs(30))
130 .connect_timeout(Duration::from_secs(10))
131 .tcp_keepalive(Duration::from_secs(60))
132 .build()
133 .map_err(|e| {
134 PluginError::Internal(format!("Failed to build HTTP client: {e}"))
135 })?,
136 ),
137 };
138
139 self.organization = config::organization_of(&server_url);
140 self.status_source = status_source;
141 self.client = Some(client::AzureDevOpsClient::new(
142 http_client,
143 server_url,
144 token,
145 ));
146 self.provider_id = Some(provider_id);
147 self.config = config;
148 Ok(())
149 }
150
151 async fn validate_credentials(&self) -> PluginResult<bool> {
152 self.client()?.ping().await?;
153 Ok(true)
154 }
155
156 async fn fetch_available_pipelines(
157 &self, params: Option<PaginationParams>,
158 ) -> PluginResult<PaginatedAvailablePipelines> {
159 self.fetch_available_pipelines_filtered(None, None, params)
160 .await
161 }
162
163 async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
164 self.client()?;
165 Ok(vec![Organization {
166 id: self.organization.clone(),
167 name: self.organization.clone(),
168 description: None,
169 }])
170 }
171
172 async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
173 let client = self.client()?;
174 Ok(retain_with_ci(ids, |project| async move {
175 client.has_definitions(&project).await
176 })
177 .await)
178 }
179
180 async fn fetch_available_pipelines_filtered(
181 &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
182 ) -> PluginResult<PaginatedAvailablePipelines> {
183 let client = self.client()?;
184 let params = params.unwrap_or_default();
185 let start = params
186 .calculate_offset()
187 .map_err(PluginError::InvalidConfig)?;
188
189 let mut projects = if org.is_none_or(|org| org == self.organization) {
190 client.list_projects().await?
191 } else {
192 Vec::new()
193 };
194 if let Some(term) = search.as_deref().map(str::to_lowercase) {
195 projects.retain(|project| {
196 project.name.to_lowercase().contains(&term)
197 || project
198 .description
199 .as_deref()
200 .is_some_and(|description| description.to_lowercase().contains(&term))
201 });
202 }
203 projects.sort_by_cached_key(|project| project.name.to_lowercase());
204
205 let total_count = projects.len();
206 let items = projects
207 .iter()
208 .skip(start)
209 .take(params.page_size)
210 .map(|project| mapper::map_available_pipeline(project, &self.organization))
211 .collect::<Vec<_>>();
212 let mut response =
213 PaginatedResponse::new(items, params.page, params.page_size, total_count);
214 response.has_more = start + response.items.len() < total_count;
215 Ok(response)
216 }
217
218 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
219 let provider_id = self.provider_id()?;
220 let projects = config::get_projects(&self.config);
221 if projects.is_empty() {
222 return Err(PluginError::InvalidConfig(
223 "No projects configured".to_string(),
224 ));
225 }
226
227 let results = try_join_all(
228 projects
229 .iter()
230 .map(|project| self.fetch_project_pipelines(provider_id, project)),
231 )
232 .await?;
233
234 Ok(results.into_iter().flatten().collect())
235 }
236
237 async fn fetch_run_history(
238 &self, pipeline_id: &str, limit: usize,
239 ) -> PluginResult<Vec<PipelineRun>> {
240 let id = config::parse_pipeline_id(pipeline_id)?;
241 let client = self.client()?;
242 let builds = client
243 .list_builds(id.project, id.definition_id, limit)
244 .await?;
245 Ok(builds
246 .iter()
247 .map(|build| mapper::map_run(build, pipeline_id, id.project, client.server_url()))
248 .collect())
249 }
250
251 async fn fetch_run_details(
252 &self, pipeline_id: &str, run_number: i64,
253 ) -> PluginResult<PipelineRun> {
254 let id = config::parse_pipeline_id(pipeline_id)?;
255 let build = self
256 .owned_build(id.project, id.definition_id, pipeline_id, run_number)
257 .await?;
258 Ok(mapper::map_run(
259 &build,
260 pipeline_id,
261 id.project,
262 self.client()?.server_url(),
263 ))
264 }
265
266 async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
267 let id = config::parse_pipeline_id(run.pipeline_id)?;
268 let timeline = self
269 .client()?
270 .get_timeline(id.project, run.run_number)
271 .await?;
272 Ok(mapper::timeline_to_log_nodes(&timeline))
273 }
274
275 async fn read_log(
276 &self, run: RunRef<'_>, node_id: &str, from: Option<&LogCursor>, sink: &mut LogSink,
277 ) -> PluginResult<LogRead> {
278 let id = config::parse_pipeline_id(run.pipeline_id)?;
279 let record_id = Self::log_node_record_id(node_id)?;
280 let client = self.client()?;
281 let timeline = client.get_timeline(id.project, run.run_number).await?;
282 let (log_id, attempt) = mapper::job_log_ref(&timeline, record_id).ok_or_else(|| {
283 PluginError::PipelineNotFound(format!(
284 "Run #{} has no job log {node_id}",
285 run.run_number
286 ))
287 })?;
288 client
289 .stream_build_log(id.project, run.run_number, log_id, attempt, from, sink)
290 .await
291 }
292
293 fn log_dialect(&self) -> &'static dyn LogDialect {
294 &AzureDevOpsLogDialect
295 }
296
297 async fn fetch_workflow_parameters(
298 &self, workflow_id: &str,
299 ) -> PluginResult<Vec<WorkflowParameter>> {
300 let id = config::parse_pipeline_id(workflow_id)?;
301 let definition = self
302 .client()?
303 .get_definition(id.project, id.definition_id)
304 .await?;
305
306 let default_branch = definition
307 .repository
308 .and_then(|repository| repository.default_branch)
309 .filter(|branch| !branch.is_empty())
310 .map(|branch| mapper::short_branch(&branch).to_string());
311 let mut parameters = vec![WorkflowParameter {
312 name: mapper::BRANCH_INPUT.to_string(),
313 label: Some("Branch".to_string()),
314 description: Some(
315 "Branch to build. The definition's default branch is used when empty".to_string(),
316 ),
317 param_type: WorkflowParameterType::String {
318 default: default_branch,
319 },
320 required: false,
321 }];
322 parameters.extend(
323 definition
324 .variables
325 .unwrap_or_default()
326 .into_iter()
327 .filter(|(_, variable)| variable.is_overridable())
328 .map(|(name, variable)| WorkflowParameter {
329 label: Some(name.clone()),
330 description: Some("Pipeline variable".to_string()),
331 param_type: WorkflowParameterType::String {
332 default: variable.value,
333 },
334 required: false,
335 name,
336 }),
337 );
338 Ok(parameters)
339 }
340
341 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
342 let id = config::parse_pipeline_id(¶ms.workflow_id)?;
343 let client = self.client()?;
344 let overridable: HashSet<String> = client
345 .get_definition(id.project, id.definition_id)
346 .await?
347 .variables
348 .unwrap_or_default()
349 .into_iter()
350 .filter(|(_, variable)| variable.is_overridable())
351 .map(|(name, _)| name.to_lowercase())
352 .collect();
353 let body = mapper::trigger_body(id.definition_id, params.inputs, &overridable);
354 let build = client.queue_build(id.project, &body).await?;
355 let logs_url = build
356 .web_link()
357 .map(str::to_string)
358 .unwrap_or_else(|| config::build_web_url(client.server_url(), id.project, build.id));
359 let branch = body["sourceBranch"]
360 .as_str()
361 .map(mapper::short_branch)
362 .unwrap_or("the default branch");
363 Ok(serde_json::json!({
364 "message": format!("Queued build {} on {branch}", build.id),
365 "run_number": build.id,
366 "logs_url": logs_url
367 })
368 .to_string())
369 }
370
371 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
372 let id = config::parse_pipeline_id(pipeline_id)?;
373 let client = self.client()?;
374 self.owned_build(id.project, id.definition_id, pipeline_id, run_number)
375 .await?;
376 client.cancel_build(id.project, run_number).await
377 }
378}
379
380#[cfg(all(test, not(target_os = "ios")))]
381mod tests {
382 use std::net::SocketAddr;
383 use std::sync::{
384 Mutex,
385 MutexGuard,
386 };
387
388 use serde_json::{
389 json,
390 Value,
391 };
392 use tokio::io::{
393 AsyncReadExt,
394 AsyncWriteExt,
395 };
396 use tokio::net::{
397 TcpListener,
398 TcpStream,
399 };
400
401 use super::*;
402 use crate::log_tail::{
403 LogPosition,
404 Tally,
405 BOUNDARY_LINE_LIMIT,
406 };
407
408 const BASIC_AUTH: &str = "Basic OnN0dWItdG9rZW4=";
409
410 struct Request {
411 method: String,
412 url: reqwest::Url,
413 authorization: Option<String>,
414 accept: Option<String>,
415 body: String,
416 }
417
418 impl Request {
419 fn query(&self, key: &str) -> Option<String> {
420 self.url
421 .query_pairs()
422 .find(|(name, _)| name == key)
423 .map(|(_, value)| value.into_owned())
424 }
425 }
426
427 struct Reply {
428 status: u16,
429 content_type: &'static str,
430 continuation: Option<String>,
431 body: String,
432 }
433
434 impl Reply {
435 fn json(status: u16, body: Value) -> Self {
436 Self {
437 status,
438 content_type: "application/json; charset=utf-8",
439 continuation: None,
440 body: body.to_string(),
441 }
442 }
443
444 fn html(status: u16, body: &str) -> Self {
445 Self {
446 status,
447 content_type: "text/html; charset=utf-8",
448 continuation: None,
449 body: body.to_string(),
450 }
451 }
452
453 fn text(status: u16, body: &str) -> Self {
454 Self {
455 status,
456 content_type: "text/plain; charset=utf-8",
457 continuation: None,
458 body: body.to_string(),
459 }
460 }
461
462 fn not_found(request: &Request) -> Self {
463 Self::json(
464 404,
465 json!({"message": format!("unexpected {} {}", request.method, request.url)}),
466 )
467 }
468
469 fn continued(mut self, token: &str) -> Self {
470 self.continuation = Some(token.to_string());
471 self
472 }
473
474 fn to_http(&self) -> String {
475 let continuation = self
476 .continuation
477 .as_ref()
478 .map(|token| format!("x-ms-continuationtoken: {token}\r\n"))
479 .unwrap_or_default();
480 format!(
481 "HTTP/1.1 {} X\r\nContent-Type: {}\r\n{continuation}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
482 self.status,
483 self.content_type,
484 self.body.len(),
485 self.body
486 )
487 }
488 }
489
490 struct Stub {
491 base_url: String,
492 requests: Arc<Mutex<Vec<Request>>>,
493 server: tokio::task::JoinHandle<()>,
494 }
495
496 impl Stub {
497 fn requests(&self) -> MutexGuard<'_, Vec<Request>> {
498 self.requests.lock().unwrap()
499 }
500
501 fn requests_to(&self, method: &str, path: &str) -> Vec<(Option<String>, Option<String>)> {
502 self.requests()
503 .iter()
504 .filter(|request| request.method == method && request.url.path() == path)
505 .map(|request| (request.query("$top"), request.query("continuationToken")))
506 .collect()
507 }
508 }
509
510 impl Drop for Stub {
511 fn drop(&mut self) {
512 self.server.abort();
513 }
514 }
515
516 async fn read_request(stream: &mut TcpStream, addr: SocketAddr) -> Request {
517 let mut raw = Vec::new();
518 let mut buffer = [0; 4096];
519 let header_end = loop {
520 if let Some(position) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
521 break position + 4;
522 }
523 let count = stream.read(&mut buffer).await.unwrap();
524 assert!(count > 0, "connection closed before the request head ended");
525 raw.extend_from_slice(&buffer[..count]);
526 };
527 let head = String::from_utf8(raw[..header_end].to_vec()).unwrap();
528 let header = |name: &str| {
529 head.lines()
530 .filter_map(|line| line.split_once(':'))
531 .find(|(key, _)| key.eq_ignore_ascii_case(name))
532 .map(|(_, value)| value.trim().to_string())
533 };
534 let length: usize = header("content-length")
535 .map(|value| value.parse().unwrap())
536 .unwrap_or(0);
537 while raw.len() < header_end + length {
538 let count = stream.read(&mut buffer).await.unwrap();
539 assert!(count > 0, "connection closed before the request body ended");
540 raw.extend_from_slice(&buffer[..count]);
541 }
542
543 let mut request_line = head.lines().next().unwrap().split_whitespace();
544 let method = request_line.next().unwrap().to_string();
545 let target = request_line.next().unwrap();
546 Request {
547 method,
548 url: reqwest::Url::parse(&format!("http://{addr}{target}")).unwrap(),
549 authorization: header("authorization"),
550 accept: header("accept"),
551 body: String::from_utf8(raw[header_end..header_end + length].to_vec()).unwrap(),
552 }
553 }
554
555 async fn serve(reply: fn(&Request) -> Reply) -> Stub {
556 let _ = rustls::crypto::ring::default_provider().install_default();
557 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
558 let addr = listener.local_addr().unwrap();
559 let requests = Arc::new(Mutex::new(Vec::new()));
560 let recorded = Arc::clone(&requests);
561 let server = tokio::spawn(async move {
562 loop {
563 let (mut stream, _) = listener.accept().await.unwrap();
564 let request = read_request(&mut stream, addr).await;
565 let answer = reply(&request);
566 recorded.lock().unwrap().push(request);
567 stream.write_all(answer.to_http().as_bytes()).await.unwrap();
568 }
569 });
570 Stub {
571 base_url: format!("http://{addr}"),
572 requests,
573 server,
574 }
575 }
576
577 fn plugin_for(stub: &Stub, organization_suffix: &str, selected: &str) -> AzureDevOpsPlugin {
578 let mut plugin = AzureDevOpsPlugin::new();
579 plugin
580 .initialize(
581 1,
582 HashMap::from([
583 (
584 "server_url".to_string(),
585 format!("{}/acme{organization_suffix}", stub.base_url),
586 ),
587 ("token".to_string(), "stub-token".to_string()),
588 ("selected_items".to_string(), selected.to_string()),
589 ]),
590 None,
591 )
592 .unwrap();
593 plugin
594 }
595
596 fn build_json(id: i64, status: &str, result: Option<&str>, definition_id: i64) -> Value {
597 let mut build = json!({
598 "id": id,
599 "buildNumber": format!("20261028.{}", id % 100),
600 "status": status,
601 "sourceBranch": "refs/heads/main",
602 "sourceVersion": format!("sha{id}"),
603 "queueTime": "2026-10-28T10:00:00.1234567Z",
604 "startTime": "2026-10-28T10:00:05.5Z",
605 "requestedFor": {"displayName": "Ada Lovelace"},
606 "definition": {"id": definition_id},
607 "_links": {"web": {"href": format!("https://dev.azure.com/acme/Alpha/_build/results?buildId={id}")}}
608 });
609 if let Some(result) = result {
610 build["result"] = json!(result);
611 build["finishTime"] = json!("2026-10-28T10:02:35.5Z");
612 }
613 build
614 }
615
616 fn azure_reply(request: &Request) -> Reply {
617 let token = request.query("continuationToken");
618 match (request.method.as_str(), request.url.path()) {
619 ("GET", "/acme/_apis/projects") => match token.as_deref() {
620 None => Reply::json(
621 200,
622 json!({"count": 2, "value": [
623 {"id": "p1", "name": "Alpha", "description": "Main product"},
624 {"id": "p2", "name": "Beta Team"}
625 ]}),
626 )
627 .continued("projects-2"),
628 Some("projects-2") => {
629 Reply::json(200, json!({"count": 1, "value": [{"id": "p3", "name": "Empty"}]}))
630 }
631 Some(_) => Reply::not_found(request),
632 },
633 ("GET", "/acme/Alpha/_apis/build/definitions") => match token.as_deref() {
634 None => Reply::json(
635 200,
636 json!({"count": 2, "value": [
637 {"id": 1, "name": "CI", "latestBuild": build_json(903, "completed", Some("succeeded"), 1)},
638 {"id": 2, "name": "Docs"}
639 ]}),
640 )
641 .continued("defs-2"),
642 Some("defs-2") => Reply::json(
643 200,
644 json!({"count": 2, "value": [
645 {"id": 3, "name": "Deploy", "latestBuild": build_json(905, "inProgress", None, 3)},
646 {"id": 4, "name": "Nightly", "latestBuild": build_json(906, "completed", Some("partiallySucceeded"), 4)}
647 ]}),
648 ),
649 Some(_) => Reply::not_found(request),
650 },
651 ("GET", "/acme/Beta%20Team/_apis/build/definitions") => Reply::json(
652 200,
653 json!({"count": 1, "value": [
654 {"id": 5, "name": "Mobile", "latestBuild": build_json(907, "completed", Some("canceled"), 5)}
655 ]}),
656 ),
657 ("GET", "/acme/Empty/_apis/build/definitions") => {
658 Reply::json(200, json!({"count": 0, "value": []}))
659 }
660 ("GET", "/acme/Alpha/_apis/build/definitions/2") => Reply::json(
661 200,
662 json!({
663 "id": 2, "name": "Docs",
664 "variables": {"Ref": {"value": "x", "allowOverride": true}}
665 }),
666 ),
667 ("GET", "/acme/Alpha/_apis/build/definitions/1") => Reply::json(
668 200,
669 json!({
670 "id": 1, "name": "CI",
671 "repository": {"defaultBranch": "refs/heads/main"},
672 "variables": {
673 "deployTarget": {"value": "dev", "allowOverride": true},
674 "fixed": {"value": "x"},
675 "apiKey": {"isSecret": true, "allowOverride": true},
676 "branch": {"value": "legacy", "allowOverride": true}
677 }
678 }),
679 ),
680 ("GET", "/acme/Alpha/_apis/build/builds") => {
681 let history = [
682 build_json(923, "inProgress", None, 1),
683 build_json(922, "completed", Some("failed"), 1),
684 build_json(921, "completed", Some("succeeded"), 1),
685 ];
686 let start = match token.as_deref() {
687 None => 0,
688 Some("builds-2") => 2,
689 Some(_) => return Reply::not_found(request),
690 };
691 let size = request
692 .query("$top")
693 .and_then(|top| top.parse().ok())
694 .unwrap_or(history.len())
695 .min(2);
696 let page: Vec<_> = history[start..].iter().take(size).collect();
697 let reply = Reply::json(200, json!({"count": page.len(), "value": page}));
698 if start + size < history.len() {
699 reply.continued("builds-2")
700 } else {
701 reply
702 }
703 }
704 ("GET", "/acme/Alpha/_apis/build/builds/923") => {
705 Reply::json(200, build_json(923, "inProgress", None, 1))
706 }
707 ("GET", "/acme/Alpha/_apis/build/builds/922") => {
708 Reply::json(200, build_json(922, "completed", Some("failed"), 1))
709 }
710 ("GET", "/acme/Alpha/_apis/build/builds/777") => {
711 Reply::json(200, build_json(777, "completed", Some("succeeded"), 9))
712 }
713 ("POST", "/acme/Alpha/_apis/build/builds") => {
714 Reply::json(200, build_json(930, "notStarted", None, 1))
715 }
716 ("PATCH", "/acme/Alpha/_apis/build/builds/923") => {
717 Reply::json(200, build_json(923, "cancelling", None, 1))
718 }
719 _ => Reply::not_found(request),
720 }
721 }
722
723 #[tokio::test]
724 async fn projects_are_listed_across_continuation_pages_and_paged_for_the_picker() {
725 let stub = serve(azure_reply).await;
726 let plugin = plugin_for(&stub, "", "");
727
728 let first = plugin
729 .fetch_available_pipelines(Some(PaginationParams {
730 page: 1,
731 page_size: 2,
732 }))
733 .await
734 .unwrap();
735 let second = plugin
736 .fetch_available_pipelines(Some(PaginationParams {
737 page: 2,
738 page_size: 2,
739 }))
740 .await
741 .unwrap();
742
743 let names = |page: &PaginatedAvailablePipelines| {
744 page.items
745 .iter()
746 .map(|item| item.id.clone())
747 .collect::<Vec<_>>()
748 };
749 assert_eq!(names(&first), ["Alpha", "Beta Team"]);
750 assert!(first.has_more);
751 assert_eq!(first.total_count, 3);
752 assert_eq!(first.items[0].description.as_deref(), Some("Main product"));
753 assert_eq!(first.items[0].organization.as_deref(), Some("acme"));
754 assert_eq!(names(&second), ["Empty"]);
755 assert!(!second.has_more);
756
757 assert_eq!(
758 stub.requests_to("GET", "/acme/_apis/projects"),
759 [
760 (Some("100".to_string()), None),
761 (Some("100".to_string()), Some("projects-2".to_string())),
762 (Some("100".to_string()), None),
763 (Some("100".to_string()), Some("projects-2".to_string())),
764 ]
765 );
766 assert!(stub.requests().iter().all(|request| {
767 request.query("api-version").as_deref() == Some("7.0")
768 && request.authorization.as_deref() == Some(BASIC_AUTH)
769 }));
770 }
771
772 #[tokio::test]
773 async fn picker_filters_by_organization_and_search_term() {
774 let stub = serve(azure_reply).await;
775 let plugin = plugin_for(&stub, "", "");
776
777 let found = plugin
778 .fetch_available_pipelines_filtered(
779 Some("acme".to_string()),
780 Some("TEAM".to_string()),
781 None,
782 )
783 .await
784 .unwrap();
785 assert_eq!(found.items.len(), 1);
786 assert_eq!(found.items[0].name, "Beta Team");
787
788 let requests_before = stub.requests().len();
789 let other = plugin
790 .fetch_available_pipelines_filtered(Some("other".to_string()), None, None)
791 .await
792 .unwrap();
793 assert!(other.items.is_empty());
794 assert_eq!(stub.requests().len(), requests_before);
795
796 let organizations = plugin.fetch_organizations().await.unwrap();
797 assert_eq!(organizations.len(), 1);
798 assert_eq!(organizations[0].id, "acme");
799 assert_eq!(organizations[0].name, "acme");
800 }
801
802 #[tokio::test]
803 async fn definitions_of_selected_projects_become_pipelines_across_continuation_pages() {
804 let stub = serve(azure_reply).await;
805 let plugin = plugin_for(&stub, "", "Alpha, Beta Team");
806
807 let pipelines = plugin.fetch_pipelines().await.unwrap();
808
809 let summary: Vec<_> = pipelines
810 .iter()
811 .map(|pipeline| {
812 (
813 pipeline.id.as_str(),
814 pipeline.name.as_str(),
815 pipeline.status,
816 pipeline.repository.as_str(),
817 pipeline.branch.as_deref(),
818 )
819 })
820 .collect();
821 assert_eq!(
822 summary,
823 [
824 (
825 "azure-devops__1__Alpha/1",
826 "CI",
827 PipelineStatus::Success,
828 "acme/Alpha",
829 Some("main")
830 ),
831 (
832 "azure-devops__1__Alpha/2",
833 "Docs",
834 PipelineStatus::Pending,
835 "acme/Alpha",
836 None
837 ),
838 (
839 "azure-devops__1__Alpha/3",
840 "Deploy",
841 PipelineStatus::Running,
842 "acme/Alpha",
843 Some("main")
844 ),
845 (
846 "azure-devops__1__Alpha/4",
847 "Nightly",
848 PipelineStatus::Failed,
849 "acme/Alpha",
850 Some("main")
851 ),
852 (
853 "azure-devops__1__Beta Team/5",
854 "Mobile",
855 PipelineStatus::Cancelled,
856 "acme/Beta Team",
857 Some("main")
858 ),
859 ]
860 );
861 assert!(pipelines
862 .iter()
863 .all(|pipeline| pipeline.provider_type == "azure-devops" && pipeline.provider_id == 1));
864 assert_eq!(
865 stub.requests_to("GET", "/acme/Alpha/_apis/build/definitions"),
866 [
867 (Some("100".to_string()), None),
868 (Some("100".to_string()), Some("defs-2".to_string())),
869 ]
870 );
871 assert!(stub
872 .requests()
873 .iter()
874 .filter(|request| request.url.path().ends_with("/build/definitions"))
875 .all(|request| request.query("includeLatestBuilds").as_deref() == Some("true")));
876 }
877
878 #[tokio::test]
879 async fn fetching_pipelines_without_selected_projects_is_a_configuration_error() {
880 let stub = serve(azure_reply).await;
881 let error = plugin_for(&stub, "", "")
882 .fetch_pipelines()
883 .await
884 .unwrap_err();
885 assert!(matches!(error, PluginError::InvalidConfig(_)), "{error}");
886 }
887
888 #[tokio::test]
889 async fn a_trailing_slash_on_the_server_url_does_not_double_the_slash() {
890 let stub = serve(azure_reply).await;
891 let plugin = plugin_for(&stub, "/", "Alpha");
892
893 plugin.fetch_available_pipelines(None).await.unwrap();
894 plugin.fetch_pipelines().await.unwrap();
895 plugin.validate_credentials().await.unwrap();
896
897 let paths: Vec<_> = stub
898 .requests()
899 .iter()
900 .map(|request| request.url.path().to_string())
901 .collect();
902 assert!(!paths.is_empty());
903 assert!(paths.iter().all(|path| !path.contains("//")), "{paths:?}");
904 assert!(
905 paths.iter().all(|path| path.starts_with("/acme/")),
906 "{paths:?}"
907 );
908 }
909
910 #[tokio::test]
911 async fn run_history_is_newest_first_and_follows_continuation_until_the_limit() {
912 let stub = serve(azure_reply).await;
913 let plugin = plugin_for(&stub, "", "Alpha");
914
915 let runs = plugin
916 .fetch_run_history("azure-devops__1__Alpha/1", 3)
917 .await
918 .unwrap();
919
920 let summary: Vec<_> = runs
921 .iter()
922 .map(|run| (run.run_number, run.status, run.id.as_str()))
923 .collect();
924 assert_eq!(
925 summary,
926 [
927 (923, PipelineStatus::Running, "azure-devops-run-923"),
928 (922, PipelineStatus::Failed, "azure-devops-run-922"),
929 (921, PipelineStatus::Success, "azure-devops-run-921"),
930 ]
931 );
932 assert_eq!(runs[0].pipeline_id, "azure-devops__1__Alpha/1");
933 assert_eq!(runs[1].branch.as_deref(), Some("main"));
934 assert_eq!(runs[1].actor.as_deref(), Some("Ada Lovelace"));
935 assert_eq!(runs[1].duration_seconds, Some(150));
936 assert_eq!(runs[1].metadata["build_number"], json!("20261028.22"));
937
938 let requests = stub.requests();
939 assert_eq!(requests.len(), 2);
940 assert_eq!(requests[0].query("definitions").as_deref(), Some("1"));
941 assert_eq!(
942 requests[0].query("queryOrder").as_deref(),
943 Some("queueTimeDescending")
944 );
945 assert_eq!(requests[0].query("$top").as_deref(), Some("3"));
946 assert_eq!(requests[0].query("continuationToken"), None);
947 assert_eq!(requests[1].query("$top").as_deref(), Some("1"));
948 assert_eq!(
949 requests[1].query("continuationToken").as_deref(),
950 Some("builds-2")
951 );
952 }
953
954 #[tokio::test]
955 async fn run_history_stops_at_the_limit_without_following_the_continuation() {
956 let stub = serve(azure_reply).await;
957 let plugin = plugin_for(&stub, "", "Alpha");
958
959 let runs = plugin
960 .fetch_run_history("azure-devops__1__Alpha/1", 1)
961 .await
962 .unwrap();
963
964 assert_eq!(runs.len(), 1);
965 assert_eq!(runs[0].run_number, 923);
966 assert_eq!(stub.requests().len(), 1);
967 assert!(plugin
968 .fetch_run_history("azure-devops__1__Alpha/1", 0)
969 .await
970 .unwrap()
971 .is_empty());
972 assert_eq!(stub.requests().len(), 1);
973 }
974
975 #[tokio::test]
976 async fn run_details_are_fetched_by_build_id_and_checked_against_the_pipeline() {
977 let stub = serve(azure_reply).await;
978 let plugin = plugin_for(&stub, "", "Alpha");
979
980 let run = plugin
981 .fetch_run_details("azure-devops__1__Alpha/1", 922)
982 .await
983 .unwrap();
984 assert_eq!(run.run_number, 922);
985 assert_eq!(run.status, PipelineStatus::Failed);
986 assert_eq!(run.commit_sha.as_deref(), Some("sha922"));
987 assert_eq!(
988 run.logs_url,
989 "https://dev.azure.com/acme/Alpha/_build/results?buildId=922"
990 );
991 assert_eq!(run.inputs, Some(json!({"source-branch": "main"})));
992
993 let foreign = plugin
994 .fetch_run_details("azure-devops__1__Alpha/1", 777)
995 .await
996 .unwrap_err();
997 assert!(
998 matches!(foreign, PluginError::PipelineNotFound(_)),
999 "{foreign}"
1000 );
1001 let missing = plugin
1002 .fetch_run_details("azure-devops__1__Alpha/1", 404)
1003 .await
1004 .unwrap_err();
1005 assert!(
1006 matches!(missing, PluginError::PipelineNotFound(_)),
1007 "{missing}"
1008 );
1009 let malformed = plugin
1010 .fetch_run_details("github__1__Alpha/1", 922)
1011 .await
1012 .unwrap_err();
1013 assert!(
1014 matches!(malformed, PluginError::InvalidConfig(_)),
1015 "{malformed}"
1016 );
1017 }
1018
1019 #[tokio::test]
1020 async fn workflow_parameters_offer_the_default_branch_and_overridable_non_secret_variables() {
1021 let stub = serve(azure_reply).await;
1022 let plugin = plugin_for(&stub, "", "Alpha");
1023
1024 let parameters = plugin
1025 .fetch_workflow_parameters("azure-devops__1__Alpha/1")
1026 .await
1027 .unwrap();
1028
1029 let summary: Vec<_> = parameters
1030 .iter()
1031 .map(|parameter| {
1032 let WorkflowParameterType::String { default } = ¶meter.param_type else {
1033 panic!("expected a string parameter: {}", parameter.name);
1034 };
1035 (
1036 parameter.name.as_str(),
1037 default.as_deref(),
1038 parameter.required,
1039 )
1040 })
1041 .collect();
1042 assert_eq!(
1043 summary,
1044 [
1045 ("source-branch", Some("main"), false),
1046 ("branch", Some("legacy"), false),
1047 ("deployTarget", Some("dev"), false),
1048 ]
1049 );
1050 }
1051
1052 #[tokio::test]
1053 async fn trigger_queues_a_build_on_the_branch_with_the_overridden_variable() {
1054 let stub = serve(azure_reply).await;
1055 let plugin = plugin_for(&stub, "", "Alpha");
1056
1057 let result = plugin
1058 .trigger_pipeline(TriggerParams {
1059 workflow_id: "azure-devops__1__Alpha/1".to_string(),
1060 inputs: Some(json!({"source-branch": "feature/x", "deployTarget": "staging"})),
1061 })
1062 .await
1063 .unwrap();
1064
1065 let result: Value = serde_json::from_str(&result).unwrap();
1066 assert_eq!(
1067 result,
1068 json!({
1069 "message": "Queued build 930 on feature/x",
1070 "run_number": 930,
1071 "logs_url": "https://dev.azure.com/acme/Alpha/_build/results?buildId=930"
1072 })
1073 );
1074 let requests = stub.requests();
1075 assert_eq!(requests.len(), 2);
1076 assert_eq!(requests[0].method, "GET");
1077 assert_eq!(
1078 requests[0].url.path(),
1079 "/acme/Alpha/_apis/build/definitions/1"
1080 );
1081 let queued = &requests[1];
1082 assert_eq!(queued.method, "POST");
1083 assert_eq!(queued.url.path(), "/acme/Alpha/_apis/build/builds");
1084 assert_eq!(queued.query("api-version").as_deref(), Some("7.0"));
1085 assert_eq!(queued.authorization.as_deref(), Some(BASIC_AUTH));
1086 let body: Value = serde_json::from_str(&queued.body).unwrap();
1087 assert_eq!(
1088 body,
1089 json!({
1090 "definition": {"id": 1},
1091 "sourceBranch": "refs/heads/feature/x",
1092 "parameters": r#"{"deployTarget":"staging"}"#
1093 })
1094 );
1095 }
1096
1097 #[tokio::test]
1098 async fn a_variable_named_branch_is_sent_as_a_variable_not_as_the_source_branch() {
1099 let stub = serve(azure_reply).await;
1100 let plugin = plugin_for(&stub, "", "Alpha");
1101
1102 plugin
1103 .trigger_pipeline(TriggerParams {
1104 workflow_id: "azure-devops__1__Alpha/1".to_string(),
1105 inputs: Some(json!({"source-branch": "main", "branch": "legacy"})),
1106 })
1107 .await
1108 .unwrap();
1109
1110 let requests = stub.requests();
1111 assert_eq!(
1112 requests[0].url.path(),
1113 "/acme/Alpha/_apis/build/definitions/1"
1114 );
1115 let body: Value = serde_json::from_str(&requests[1].body).unwrap();
1116 assert_eq!(
1117 body,
1118 json!({
1119 "definition": {"id": 1},
1120 "sourceBranch": "refs/heads/main",
1121 "parameters": r#"{"branch":"legacy"}"#
1122 })
1123 );
1124 }
1125
1126 async fn queued_body(
1127 plugin: &AzureDevOpsPlugin, stub: &Stub, pipeline: &str, inputs: Value,
1128 ) -> Value {
1129 plugin
1130 .trigger_pipeline(TriggerParams {
1131 workflow_id: pipeline.to_string(),
1132 inputs: Some(inputs),
1133 })
1134 .await
1135 .unwrap();
1136 let requests = stub.requests();
1137 let queued = requests
1138 .iter()
1139 .find(|request| request.method == "POST")
1140 .unwrap();
1141 serde_json::from_str(&queued.body).unwrap()
1142 }
1143
1144 #[tokio::test]
1145 async fn an_overridable_ref_variable_is_sent_even_when_it_equals_the_branch() {
1146 let stub = serve(azure_reply).await;
1147 let plugin = plugin_for(&stub, "", "Alpha");
1148
1149 let body = queued_body(
1150 &plugin,
1151 &stub,
1152 "azure-devops__1__Alpha/2",
1153 json!({"source-branch": "main", "ref": "main"}),
1154 )
1155 .await;
1156
1157 assert_eq!(
1158 body,
1159 json!({
1160 "definition": {"id": 2},
1161 "sourceBranch": "refs/heads/main",
1162 "parameters": r#"{"ref":"main"}"#
1163 })
1164 );
1165 }
1166
1167 #[tokio::test]
1168 async fn the_rerun_ref_echo_is_dropped_when_the_definition_has_no_overridable_ref() {
1169 let stub = serve(azure_reply).await;
1170 let plugin = plugin_for(&stub, "", "Alpha");
1171
1172 let body = queued_body(
1173 &plugin,
1174 &stub,
1175 "azure-devops__1__Alpha/1",
1176 json!({"source-branch": "main", "ref": "main"}),
1177 )
1178 .await;
1179
1180 assert_eq!(
1181 body,
1182 json!({"definition": {"id": 1}, "sourceBranch": "refs/heads/main"})
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn inputs_that_are_not_overridable_variables_are_not_sent() {
1188 let stub = serve(azure_reply).await;
1189 let plugin = plugin_for(&stub, "", "Alpha");
1190
1191 let body = queued_body(
1192 &plugin,
1193 &stub,
1194 "azure-devops__1__Alpha/1",
1195 json!({"DEPLOYtarget": "prod", "fixed": "x", "apiKey": "s3cret", "unknown": "y"}),
1196 )
1197 .await;
1198
1199 assert_eq!(
1200 body,
1201 json!({"definition": {"id": 1}, "parameters": r#"{"DEPLOYtarget":"prod"}"#})
1202 );
1203 }
1204
1205 #[tokio::test]
1206 async fn cancel_patches_the_build_status_to_cancelling() {
1207 let stub = serve(azure_reply).await;
1208 let plugin = plugin_for(&stub, "", "Alpha");
1209
1210 plugin
1211 .cancel_run("azure-devops__1__Alpha/1", 923)
1212 .await
1213 .unwrap();
1214
1215 let requests = stub.requests();
1216 assert_eq!(requests.len(), 2);
1217 assert_eq!(requests[0].method, "GET");
1218 assert_eq!(requests[0].url.path(), "/acme/Alpha/_apis/build/builds/923");
1219 assert_eq!(requests[1].method, "PATCH");
1220 assert_eq!(requests[1].url.path(), "/acme/Alpha/_apis/build/builds/923");
1221 assert_eq!(requests[1].query("api-version").as_deref(), Some("7.0"));
1222 let body: Value = serde_json::from_str(&requests[1].body).unwrap();
1223 assert_eq!(body, json!({"status": "cancelling"}));
1224 assert!(plugin.metadata().capabilities.cancel);
1225 }
1226
1227 #[tokio::test]
1228 async fn cancel_refuses_a_build_of_another_definition_without_patching() {
1229 let stub = serve(azure_reply).await;
1230 let plugin = plugin_for(&stub, "", "Alpha");
1231
1232 let error = plugin
1233 .cancel_run("azure-devops__1__Alpha/1", 777)
1234 .await
1235 .unwrap_err();
1236
1237 assert!(matches!(error, PluginError::PipelineNotFound(_)), "{error}");
1238 assert!(error.to_string().contains("does not belong"), "{error}");
1239 assert!(stub
1240 .requests()
1241 .iter()
1242 .all(|request| request.method == "GET"));
1243 }
1244
1245 #[tokio::test]
1246 async fn ids_with_ci_keeps_projects_that_have_definitions() {
1247 let stub = serve(azure_reply).await;
1248 let plugin = plugin_for(&stub, "", "");
1249
1250 let kept = plugin
1251 .ids_with_ci(vec!["Alpha".to_string(), "Empty".to_string()])
1252 .await
1253 .unwrap();
1254
1255 assert_eq!(kept, ["Alpha"]);
1256 }
1257
1258 #[tokio::test]
1259 async fn validate_accepts_a_working_token_with_a_single_cheap_call() {
1260 let stub = serve(|_| Reply::json(200, json!({"count": 0, "value": []}))).await;
1261
1262 assert!(plugin_for(&stub, "", "")
1263 .validate_credentials()
1264 .await
1265 .unwrap());
1266
1267 let requests = stub.requests();
1268 assert_eq!(requests.len(), 1);
1269 assert_eq!(requests[0].url.path(), "/acme/_apis/projects");
1270 assert_eq!(requests[0].query("$top").as_deref(), Some("1"));
1271 }
1272
1273 #[tokio::test]
1274 async fn validate_maps_401_to_authentication_failed() {
1275 let stub =
1276 serve(|_| Reply::json(401, json!({"message": "TF400813: not authorized"}))).await;
1277
1278 let error = plugin_for(&stub, "", "")
1279 .validate_credentials()
1280 .await
1281 .unwrap_err();
1282
1283 assert!(
1284 matches!(error, PluginError::AuthenticationFailed(_)),
1285 "{error}"
1286 );
1287 assert_eq!(stub.requests().len(), 1);
1288 }
1289
1290 #[tokio::test]
1291 async fn validate_maps_a_203_sign_in_page_to_authentication_failed() {
1292 let stub = serve(|_| Reply::html(203, "<html><title>Sign In</title></html>")).await;
1293
1294 let error = plugin_for(&stub, "", "")
1295 .validate_credentials()
1296 .await
1297 .unwrap_err();
1298
1299 assert!(
1300 matches!(error, PluginError::AuthenticationFailed(_)),
1301 "{error}"
1302 );
1303 assert!(error.to_string().contains("sign-in page"), "{error}");
1304 assert_eq!(stub.requests().len(), 1);
1305 }
1306
1307 #[tokio::test]
1308 async fn validate_maps_a_200_html_sign_in_page_to_authentication_failed() {
1309 let stub = serve(|_| Reply::html(200, "<html><title>Sign In</title></html>")).await;
1310
1311 let error = plugin_for(&stub, "", "")
1312 .validate_credentials()
1313 .await
1314 .unwrap_err();
1315
1316 assert!(
1317 matches!(error, PluginError::AuthenticationFailed(_)),
1318 "{error}"
1319 );
1320 }
1321
1322 #[tokio::test]
1323 async fn validate_maps_403_to_permission_denied() {
1324 let stub = serve(|_| Reply::json(403, json!({"message": "TF401444: no access"}))).await;
1325
1326 let error = plugin_for(&stub, "", "")
1327 .validate_credentials()
1328 .await
1329 .unwrap_err();
1330
1331 assert!(matches!(error, PluginError::PermissionDenied(_)), "{error}");
1332 }
1333
1334 #[test]
1335 fn initialize_requires_a_server_url_and_a_token() {
1336 let missing_url = AzureDevOpsPlugin::new()
1337 .initialize(
1338 1,
1339 HashMap::from([("token".to_string(), "t".to_string())]),
1340 None,
1341 )
1342 .unwrap_err();
1343 let missing_token = AzureDevOpsPlugin::new()
1344 .initialize(
1345 1,
1346 HashMap::from([(
1347 "server_url".to_string(),
1348 "https://dev.azure.com/acme".to_string(),
1349 )]),
1350 None,
1351 )
1352 .unwrap_err();
1353
1354 assert!(matches!(missing_url, PluginError::InvalidConfig(_)));
1355 assert!(matches!(missing_token, PluginError::InvalidConfig(_)));
1356 }
1357
1358 #[tokio::test]
1359 async fn a_list_cut_short_by_the_page_cap_is_an_error() {
1360 let stub = serve(|_| {
1361 Reply::json(
1362 200,
1363 json!({"count": 1, "value": [{"id": "p", "name": "Alpha"}]}),
1364 )
1365 .continued("again")
1366 })
1367 .await;
1368
1369 let error = plugin_for(&stub, "", "")
1370 .fetch_available_pipelines(None)
1371 .await
1372 .unwrap_err();
1373
1374 assert!(matches!(error, PluginError::ApiError(_)), "{error}");
1375 assert!(error.to_string().contains("more items"), "{error}");
1376 assert!(stub.requests().len() > 1);
1377 }
1378
1379 const STAGE: &str = "a0000000-0000-4000-8000-000000000001";
1380 const PHASE: &str = "b0000000-0000-4000-8000-000000000001";
1381 const JOB_LINUX: &str = "c0000000-0000-4000-8000-000000000001";
1382 const JOB_DOCS: &str = "c0000000-0000-4000-8000-000000000002";
1383 const JOB_PRUNED: &str = "c0000000-0000-4000-8000-000000000003";
1384 const JOB_EXPIRED: &str = "c0000000-0000-4000-8000-000000000004";
1385 const TASK_INIT: &str = "d0000000-0000-4000-8000-000000000001";
1386 const TASK_TESTS: &str = "d0000000-0000-4000-8000-000000000002";
1387 const TASK_PUBLISH: &str = "d0000000-0000-4000-8000-000000000003";
1388 const TASK_DOCS: &str = "d0000000-0000-4000-8000-000000000004";
1389 const CHECKPOINT: &str = "e0000000-0000-4000-8000-000000000001";
1390 const LINUX_LOG: &str =
1391 "\u{feff}2026-10-28T10:00:00.0000000Z ##[section]Starting: Linux tests\n\
1392 2026-10-28T10:00:01.1000000Z ##[section]Starting: Initialize job\n\
1393 2026-10-28T10:00:02.0000000Z ##[section]Finishing: Initialize job\n\
1394 2026-10-28T10:00:03.0000000Z ##[section]Starting: Run tests\n\
1395 2026-10-28T10:00:03.1000000Z ##[command]make test\n\
1396 2026-10-28T10:00:09.0000000Z ##[error]Bash exited with code '2'.\n\
1397 2026-10-28T10:00:09.5000000Z ##[section]Finishing: Run tests\n";
1398
1399 type Outcome = (&'static str, Option<&'static str>);
1400
1401 const SUCCEEDED: Outcome = ("completed", Some("succeeded"));
1402 const FAILED: Outcome = ("completed", Some("failed"));
1403 const SKIPPED: Outcome = ("completed", Some("skipped"));
1404
1405 fn record(
1406 id: &str, parent: Option<&str>, kind: &str, name: &str, order: i64, outcome: Outcome,
1407 log: Option<i64>,
1408 ) -> Value {
1409 let mut record = json!({
1410 "id": id,
1411 "parentId": parent,
1412 "type": kind,
1413 "name": name,
1414 "order": order,
1415 "state": outcome.0,
1416 "result": outcome.1,
1417 "startTime": "2026-10-28T10:00:00.5000000Z",
1418 "finishTime": "2026-10-28T10:00:09.5000000Z"
1419 });
1420 if let Some(log) = log {
1421 record["log"] = json!({"id": log, "type": "Container"});
1422 }
1423 record
1424 }
1425
1426 fn timeline_922() -> Value {
1427 let mut publish = record(
1428 TASK_PUBLISH,
1429 Some(JOB_LINUX),
1430 "Task",
1431 "Publish results",
1432 3,
1433 SKIPPED,
1434 None,
1435 );
1436 publish["startTime"] = Value::Null;
1437 publish["finishTime"] = Value::Null;
1438 json!({"id": "f0000000-0000-4000-8000-000000000001", "records": [
1439 record(TASK_TESTS, Some(JOB_LINUX), "Task", "Run tests", 2, FAILED, Some(9)),
1440 record(JOB_DOCS, Some(PHASE), "Job", "Docs", 2, SUCCEEDED, Some(12)),
1441 record(STAGE, None, "Stage", "Build", 1, FAILED, None),
1442 record(PHASE, Some(STAGE), "Phase", "Linux", 1, FAILED, None),
1443 record(JOB_LINUX, Some(PHASE), "Job", "Linux tests", 1, FAILED, Some(7)),
1444 record(CHECKPOINT, Some(STAGE), "Checkpoint", "Checkpoint", 0, SUCCEEDED, None),
1445 record(TASK_INIT, Some(JOB_LINUX), "Task", "Initialize job", 1, SUCCEEDED, Some(8)),
1446 publish,
1447 record(TASK_DOCS, Some(JOB_DOCS), "Task", "Build docs", 1, SUCCEEDED, Some(13))
1448 ]})
1449 }
1450
1451 fn timeline_921() -> Value {
1452 json!({"id": "f0000000-0000-4000-8000-000000000002", "records": [
1453 record(PHASE, None, "Phase", "Phase 1", 1, FAILED, None),
1454 record(JOB_PRUNED, Some(PHASE), "Job", "Build", 1, SUCCEEDED, Some(4)),
1455 record(JOB_EXPIRED, Some(PHASE), "Job", "Test", 2, FAILED, Some(5))
1456 ]})
1457 }
1458
1459 fn log_reply(request: &Request) -> Reply {
1460 match (request.method.as_str(), request.url.path()) {
1461 ("GET", "/acme/Alpha/_apis/build/builds/922/timeline") => {
1462 Reply::json(200, timeline_922())
1463 }
1464 ("GET", "/acme/Alpha/_apis/build/builds/922/logs/7") => Reply::text(200, LINUX_LOG),
1465 ("GET", "/acme/Alpha/_apis/build/builds/921/timeline") => {
1466 Reply::json(200, timeline_921())
1467 }
1468 ("GET", "/acme/Alpha/_apis/build/builds/921/logs/4") => {
1469 Reply::json(404, json!({"message": "TF400898: log not found"}))
1470 }
1471 ("GET", "/acme/Alpha/_apis/build/builds/921/logs/5") => {
1472 Reply::json(410, json!({"message": "log expired"}))
1473 }
1474 _ => Reply::not_found(request),
1475 }
1476 }
1477
1478 fn build_of_pipeline(run_number: i64, metadata: &HashMap<String, Value>) -> RunRef<'_> {
1479 RunRef {
1480 pipeline_id: "azure-devops__1__Alpha/1",
1481 run_number,
1482 run_id: "azure-devops-run",
1483 metadata,
1484 }
1485 }
1486
1487 async fn resume_job_log(
1488 plugin: &AzureDevOpsPlugin, run_number: i64, node_id: &str, from: Option<&LogCursor>,
1489 ) -> PluginResult<(Vec<u8>, LogRead, bool)> {
1490 let metadata = HashMap::new();
1491 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1492 let mut sink = LogSink::new(tx);
1493 let result = plugin
1494 .read_log(
1495 build_of_pipeline(run_number, &metadata),
1496 node_id,
1497 from,
1498 &mut sink,
1499 )
1500 .await;
1501 let restarted = sink.restarted();
1502 drop(sink);
1503 let mut bytes = Vec::new();
1504 while let Some(chunk) = rx.recv().await {
1505 bytes.extend_from_slice(&chunk);
1506 }
1507 result.map(|read| (bytes, read, restarted))
1508 }
1509
1510 async fn read_job_log(
1511 plugin: &AzureDevOpsPlugin, run_number: i64, node_id: &str,
1512 ) -> PluginResult<Vec<u8>> {
1513 resume_job_log(plugin, run_number, node_id, None)
1514 .await
1515 .map(|(bytes, _, _)| bytes)
1516 }
1517
1518 #[tokio::test]
1519 async fn timeline_becomes_stage_job_and_step_nodes_in_order() {
1520 let stub = serve(log_reply).await;
1521 let plugin = plugin_for(&stub, "", "Alpha");
1522 let metadata = HashMap::new();
1523
1524 let nodes = plugin
1525 .fetch_log_nodes(build_of_pipeline(922, &metadata))
1526 .await
1527 .unwrap();
1528
1529 let summary: Vec<_> = nodes
1530 .iter()
1531 .map(|node| {
1532 (
1533 node.id.as_str(),
1534 node.parent_id.as_deref(),
1535 node.kind,
1536 node.name.as_str(),
1537 node.status,
1538 node.log,
1539 )
1540 })
1541 .collect();
1542 assert_eq!(
1543 summary,
1544 [
1545 (
1546 STAGE,
1547 None,
1548 LogNodeKind::Stage,
1549 "Build",
1550 PipelineStatus::Failed,
1551 None
1552 ),
1553 (
1554 JOB_LINUX,
1555 Some(STAGE),
1556 LogNodeKind::Job,
1557 "Linux tests",
1558 PipelineStatus::Failed,
1559 Some(LogAvailability::Ready)
1560 ),
1561 (
1562 TASK_INIT,
1563 Some(JOB_LINUX),
1564 LogNodeKind::Step,
1565 "Initialize job",
1566 PipelineStatus::Success,
1567 None
1568 ),
1569 (
1570 TASK_TESTS,
1571 Some(JOB_LINUX),
1572 LogNodeKind::Step,
1573 "Run tests",
1574 PipelineStatus::Failed,
1575 None
1576 ),
1577 (
1578 TASK_PUBLISH,
1579 Some(JOB_LINUX),
1580 LogNodeKind::Step,
1581 "Publish results",
1582 PipelineStatus::Skipped,
1583 None
1584 ),
1585 (
1586 JOB_DOCS,
1587 Some(STAGE),
1588 LogNodeKind::Job,
1589 "Docs",
1590 PipelineStatus::Success,
1591 Some(LogAvailability::Ready)
1592 ),
1593 (
1594 TASK_DOCS,
1595 Some(JOB_DOCS),
1596 LogNodeKind::Step,
1597 "Build docs",
1598 PipelineStatus::Success,
1599 None
1600 ),
1601 ]
1602 );
1603 assert_eq!(
1604 nodes[1].started_at.unwrap().to_rfc3339(),
1605 "2026-10-28T10:00:00.500+00:00"
1606 );
1607 assert_eq!(nodes[4].started_at, None);
1608 assert_eq!(nodes[4].finished_at, None);
1609 let requests = stub.requests();
1610 assert_eq!(requests.len(), 1);
1611 assert_eq!(
1612 requests[0].url.path(),
1613 "/acme/Alpha/_apis/build/builds/922/timeline"
1614 );
1615 assert_eq!(requests[0].query("api-version").as_deref(), Some("7.0"));
1616 }
1617
1618 #[tokio::test]
1619 async fn build_without_stages_lists_its_jobs_at_the_top() {
1620 let stub = serve(log_reply).await;
1621 let plugin = plugin_for(&stub, "", "Alpha");
1622 let metadata = HashMap::new();
1623
1624 let nodes = plugin
1625 .fetch_log_nodes(build_of_pipeline(921, &metadata))
1626 .await
1627 .unwrap();
1628
1629 let summary: Vec<_> = nodes
1630 .iter()
1631 .map(|node| (node.id.as_str(), node.parent_id.as_deref(), node.kind))
1632 .collect();
1633 assert_eq!(
1634 summary,
1635 [
1636 (JOB_PRUNED, None, LogNodeKind::Job),
1637 (JOB_EXPIRED, None, LogNodeKind::Job)
1638 ]
1639 );
1640 }
1641
1642 #[tokio::test]
1643 async fn build_without_a_timeline_has_no_log_nodes() {
1644 let stub = serve(log_reply).await;
1645 let plugin = plugin_for(&stub, "", "Alpha");
1646 let metadata = HashMap::new();
1647
1648 let error = plugin
1649 .fetch_log_nodes(build_of_pipeline(999, &metadata))
1650 .await
1651 .unwrap_err();
1652
1653 assert!(matches!(error, PluginError::PipelineNotFound(_)), "{error}");
1654 }
1655
1656 #[tokio::test]
1657 async fn job_log_streams_as_text_through_the_id_the_timeline_lists() {
1658 let stub = serve(log_reply).await;
1659 let plugin = plugin_for(&stub, "", "Alpha");
1660
1661 let log = read_job_log(&plugin, 922, JOB_LINUX).await.unwrap();
1662
1663 assert_eq!(log, LINUX_LOG.as_bytes());
1664 let requests = stub.requests();
1665 let paths: Vec<_> = requests
1666 .iter()
1667 .map(|request| request.url.path().to_string())
1668 .collect();
1669 assert_eq!(
1670 paths,
1671 [
1672 "/acme/Alpha/_apis/build/builds/922/timeline",
1673 "/acme/Alpha/_apis/build/builds/922/logs/7"
1674 ]
1675 );
1676 assert_eq!(requests[1].accept.as_deref(), Some("text/plain"));
1677 assert_eq!(requests[1].query("api-version").as_deref(), Some("7.0"));
1678 assert!(requests
1679 .iter()
1680 .all(|request| request.authorization.as_deref() == Some(BASIC_AUTH)));
1681 }
1682
1683 #[tokio::test]
1684 async fn log_the_server_no_longer_has_is_not_found() {
1685 let stub = serve(log_reply).await;
1686 let plugin = plugin_for(&stub, "", "Alpha");
1687
1688 for job in [JOB_PRUNED, JOB_EXPIRED] {
1689 let error = read_job_log(&plugin, 921, job).await.unwrap_err();
1690 assert!(
1691 matches!(&error, PluginError::PipelineNotFound(message) if message.contains("no longer available")),
1692 "{job}: {error:?}"
1693 );
1694 }
1695 }
1696
1697 #[tokio::test]
1698 async fn node_ids_outside_the_guid_alphabet_are_rejected_without_a_request() {
1699 let stub = serve(log_reply).await;
1700 let plugin = plugin_for(&stub, "", "Alpha");
1701
1702 let rejected = [
1703 "",
1704 "../../logs/7",
1705 "c0000000-0000-4000-8000-000000000001/../x",
1706 "x?y=1",
1707 "c0000000 0000",
1708 "c0000000-0000-4000-8000-00000000000g",
1709 "7/../../builds/1",
1710 ];
1711 for node_id in rejected {
1712 let error = read_job_log(&plugin, 922, node_id).await.unwrap_err();
1713 assert!(
1714 matches!(error, PluginError::InvalidConfig(_)),
1715 "{node_id}: {error:?}"
1716 );
1717 }
1718 assert!(stub.requests().is_empty());
1719 }
1720
1721 #[tokio::test]
1722 async fn node_the_timeline_does_not_list_as_a_job_log_never_reaches_the_log_endpoint() {
1723 let stub = serve(log_reply).await;
1724 let plugin = plugin_for(&stub, "", "Alpha");
1725
1726 let unlisted = [
1727 TASK_TESTS,
1728 STAGE,
1729 PHASE,
1730 CHECKPOINT,
1731 "ffffffff-0000-4000-8000-000000000000",
1732 "7",
1733 ];
1734 for node_id in unlisted {
1735 let error = read_job_log(&plugin, 922, node_id).await.unwrap_err();
1736 assert!(
1737 matches!(error, PluginError::PipelineNotFound(_)),
1738 "{node_id}: {error:?}"
1739 );
1740 }
1741 let requests = stub.requests();
1742 assert_eq!(requests.len(), unlisted.len());
1743 assert!(requests
1744 .iter()
1745 .all(|request| request.url.path().ends_with("/timeline")));
1746 }
1747
1748 const LIVE_LINES: [&str; 5] = [
1749 "2026-10-28T10:00:00.0000000Z ##[section]Starting: Linux tests",
1750 "2026-10-28T10:00:01.0000000Z ##[command]make build",
1751 "2026-10-28T10:00:02.0000000Z compiling",
1752 "2026-10-28T10:00:03.0000000Z compiling",
1753 "2026-10-28T10:00:04.0000000Z ##[section]Finishing: Linux tests",
1754 ];
1755 const REPEATED_LINES: [&str; 4] = ["q", "z", "z", "z"];
1756 const LIVE_BUILDS: [(&str, i64, i64); 4] =
1757 [("924", 7, 1), ("925", 8, 2), ("926", 9, 1), ("927", 10, 1)];
1758
1759 fn joined(lines: &[impl AsRef<str>]) -> String {
1760 lines
1761 .iter()
1762 .map(|line| format!("{}\n", line.as_ref()))
1763 .collect()
1764 }
1765
1766 fn live_text(lines: std::ops::Range<usize>) -> String {
1767 joined(&LIVE_LINES[lines])
1768 }
1769
1770 fn oversized_lines() -> Vec<String> {
1771 vec![
1772 "a".to_string(),
1773 "b".to_string(),
1774 "x".repeat(BOUNDARY_LINE_LIMIT + 10),
1775 "new".to_string(),
1776 ]
1777 }
1778
1779 fn server_lines(build: &str) -> Vec<String> {
1780 match build {
1781 "926" => REPEATED_LINES.iter().map(|line| line.to_string()).collect(),
1782 "927" => oversized_lines(),
1783 _ => LIVE_LINES.iter().map(|line| line.to_string()).collect(),
1784 }
1785 }
1786
1787 fn job_timeline(log: i64, attempt: i64) -> Value {
1788 let mut job = record(
1789 JOB_LINUX,
1790 None,
1791 "Job",
1792 "Linux tests",
1793 1,
1794 SUCCEEDED,
1795 Some(log),
1796 );
1797 job["attempt"] = json!(attempt);
1798 json!({"records": [job]})
1799 }
1800
1801 fn live_log(request: &Request, first_line: u64) -> Reply {
1802 let path = request.url.path();
1803 let Some((build, log, attempt)) = LIVE_BUILDS.iter().find(|(build, ..)| {
1804 path.starts_with(&format!("/acme/Alpha/_apis/build/builds/{build}/"))
1805 }) else {
1806 return Reply::not_found(request);
1807 };
1808 if path.ends_with("/timeline") {
1809 return Reply::json(200, job_timeline(*log, *attempt));
1810 }
1811 if !path.ends_with(&format!("/logs/{log}")) {
1812 return Reply::not_found(request);
1813 }
1814 let skip = request.query("startLine").map_or(0, |line| {
1815 line.parse::<u64>().unwrap().saturating_sub(first_line) as usize
1816 });
1817 let lines = server_lines(build);
1818 Reply::text(200, &joined(&lines[skip.min(lines.len())..]))
1819 }
1820
1821 fn zero_based_log(request: &Request) -> Reply {
1822 live_log(request, 0)
1823 }
1824
1825 fn one_based_log(request: &Request) -> Reply {
1826 live_log(request, 1)
1827 }
1828
1829 type LiveServer = (fn(&Request) -> Reply, u64);
1830
1831 const LIVE_SERVERS: [LiveServer; 2] = [(zero_based_log, 0), (one_based_log, 1)];
1832
1833 fn position_over(
1834 build: i64, log: i64, attempt: i64, text: &str, first_line: Option<u64>,
1835 ) -> LogPosition {
1836 let mut tally = Tally::default();
1837 tally.feed(text.as_bytes());
1838 let mut position = tally.position(build, log, attempt);
1839 if first_line.is_some() {
1840 position.first_line = first_line;
1841 position.anchor = None;
1842 }
1843 position
1844 }
1845
1846 fn cursor_over(
1847 build: i64, log: i64, attempt: i64, text: &str, first_line: Option<u64>,
1848 ) -> LogCursor {
1849 LogCursor::new(&position_over(build, log, attempt, text, first_line)).unwrap()
1850 }
1851
1852 fn log_requests(stub: &Stub) -> Vec<Option<String>> {
1853 stub.requests()
1854 .iter()
1855 .filter(|request| request.url.path().contains("/logs/"))
1856 .map(|request| request.query("startLine"))
1857 .collect()
1858 }
1859
1860 fn lines_asked(lines: &[Option<u64>]) -> Vec<Option<String>> {
1861 lines
1862 .iter()
1863 .map(|line| line.map(|line| line.to_string()))
1864 .collect()
1865 }
1866
1867 #[tokio::test]
1868 async fn first_read_is_the_whole_log_with_a_cursor_at_its_end() {
1869 let stub = serve(zero_based_log).await;
1870 let plugin = plugin_for(&stub, "", "Alpha");
1871
1872 let (log, read, restarted) = resume_job_log(&plugin, 924, JOB_LINUX, None).await.unwrap();
1873
1874 assert_eq!(log, live_text(0..5).as_bytes());
1875 assert_eq!(
1876 read,
1877 LogRead::replace(Some(cursor_over(924, 7, 1, &live_text(0..5), None)))
1878 );
1879 assert!(!restarted);
1880 assert_eq!(log_requests(&stub), [None]);
1881 }
1882
1883 #[tokio::test]
1884 async fn resumed_read_appends_only_the_new_lines_whichever_line_the_server_counts_from() {
1885 for (serve_log, first) in LIVE_SERVERS {
1886 let stub = serve(serve_log).await;
1887 let plugin = plugin_for(&stub, "", "Alpha");
1888 let cursor = cursor_over(924, 7, 1, &live_text(0..3), None);
1889
1890 let (log, read, restarted) = resume_job_log(&plugin, 924, JOB_LINUX, Some(&cursor))
1891 .await
1892 .unwrap();
1893
1894 assert_eq!(log, live_text(3..5).as_bytes());
1895 assert_eq!(
1896 read,
1897 LogRead::append(cursor_over(924, 7, 1, &live_text(0..5), Some(first)))
1898 );
1899 assert!(!restarted);
1900 assert_eq!(
1901 log_requests(&stub),
1902 lines_asked(&[Some(2), Some(2 + first)])
1903 );
1904 }
1905 }
1906
1907 #[tokio::test]
1908 async fn once_the_numbering_is_known_a_resume_asks_for_the_boundary_line_only() {
1909 for (serve_log, first) in LIVE_SERVERS {
1910 let stub = serve(serve_log).await;
1911 let plugin = plugin_for(&stub, "", "Alpha");
1912 let cursor = cursor_over(924, 7, 1, &live_text(0..3), Some(first));
1913
1914 let (log, read, restarted) = resume_job_log(&plugin, 924, JOB_LINUX, Some(&cursor))
1915 .await
1916 .unwrap();
1917
1918 assert_eq!(log, live_text(3..5).as_bytes());
1919 assert_eq!(
1920 read,
1921 LogRead::append(cursor_over(924, 7, 1, &live_text(0..5), Some(first)))
1922 );
1923 assert!(!restarted);
1924 assert_eq!(log_requests(&stub), lines_asked(&[Some(2 + first)]));
1925 }
1926 }
1927
1928 #[tokio::test]
1929 async fn repeated_lines_at_the_boundary_do_not_swallow_the_new_line() {
1930 let before = joined(&REPEATED_LINES[..3]);
1931 let after = joined(&REPEATED_LINES);
1932 for (serve_log, first) in LIVE_SERVERS {
1933 for known in [None, Some(first)] {
1934 let stub = serve(serve_log).await;
1935 let plugin = plugin_for(&stub, "", "Alpha");
1936 let cursor = cursor_over(926, 9, 1, &before, known);
1937
1938 let (log, read, restarted) = resume_job_log(&plugin, 926, JOB_LINUX, Some(&cursor))
1939 .await
1940 .unwrap();
1941
1942 assert_eq!(log, b"z\n", "{known:?}");
1943 assert_eq!(
1944 read,
1945 LogRead::append(cursor_over(926, 9, 1, &after, Some(first))),
1946 "{known:?}"
1947 );
1948 assert!(!restarted, "{known:?}");
1949 }
1950 }
1951 }
1952
1953 #[tokio::test]
1954 async fn resumed_read_with_nothing_new_writes_nothing_and_keeps_the_cursor() {
1955 for (serve_log, first) in LIVE_SERVERS {
1956 let stub = serve(serve_log).await;
1957 let plugin = plugin_for(&stub, "", "Alpha");
1958 let cursor = cursor_over(924, 7, 1, &live_text(0..5), None);
1959
1960 let (log, read, restarted) = resume_job_log(&plugin, 924, JOB_LINUX, Some(&cursor))
1961 .await
1962 .unwrap();
1963
1964 assert!(log.is_empty());
1965 assert_eq!(
1966 read,
1967 LogRead::append(cursor_over(924, 7, 1, &live_text(0..5), Some(first)))
1968 );
1969 assert!(!restarted);
1970 }
1971 }
1972
1973 #[tokio::test]
1974 async fn resumed_read_continues_a_last_line_that_had_no_newline_on_a_fresh_line() {
1975 for (serve_log, first) in LIVE_SERVERS {
1976 let stub = serve(serve_log).await;
1977 let plugin = plugin_for(&stub, "", "Alpha");
1978 let unterminated = live_text(0..3);
1979 let unterminated = unterminated.trim_end_matches('\n');
1980 let cursor = cursor_over(924, 7, 1, unterminated, None);
1981
1982 let (log, read, restarted) = resume_job_log(&plugin, 924, JOB_LINUX, Some(&cursor))
1983 .await
1984 .unwrap();
1985
1986 assert_eq!(log, format!("\n{}", live_text(3..5)).as_bytes());
1987 assert_eq!(
1988 read,
1989 LogRead::append(cursor_over(924, 7, 1, &live_text(0..5), Some(first)))
1990 );
1991 assert!(!restarted);
1992 }
1993 }
1994
1995 #[tokio::test]
1996 async fn boundary_line_longer_than_the_limit_restarts_with_a_full_streamed_read() {
1997 let full = joined(&oversized_lines());
1998 let seen = joined(&oversized_lines()[..3]);
1999 for (serve_log, first) in LIVE_SERVERS {
2000 let stub = serve(serve_log).await;
2001 let plugin = plugin_for(&stub, "", "Alpha");
2002 let cursor = cursor_over(927, 10, 1, &seen, Some(first));
2003
2004 let (bytes, read, restarted) = resume_job_log(&plugin, 927, JOB_LINUX, Some(&cursor))
2005 .await
2006 .unwrap();
2007
2008 assert_eq!(bytes, full.as_bytes());
2009 assert_eq!(
2010 read,
2011 LogRead::replace(Some(cursor_over(927, 10, 1, &full, None)))
2012 );
2013 assert!(restarted);
2014 assert_eq!(log_requests(&stub), lines_asked(&[Some(2 + first), None]));
2015 }
2016 }
2017
2018 #[tokio::test]
2019 async fn cursor_that_cannot_continue_the_log_restarts_with_the_whole_log() {
2020 let malformed = LogCursor::new(&"junk").unwrap();
2021
2022 for (serve_log, first) in LIVE_SERVERS {
2023 let cases = [
2024 (
2025 924,
2026 7,
2027 cursor_over(924, 7, 1, &"longer\n".repeat(9), Some(first)),
2028 3,
2029 ),
2030 (
2031 924,
2032 7,
2033 cursor_over(924, 7, 1, &"rewritten\n".repeat(5), Some(first)),
2034 3,
2035 ),
2036 (924, 7, cursor_over(924, 7, 1, "a\nb\nc\n", None), 3),
2037 (924, 7, cursor_over(924, 7, 1, "z\nz\nz\n", None), 2),
2038 (
2039 924,
2040 7,
2041 cursor_over(924, 6, 1, &live_text(0..3), Some(first)),
2042 2,
2043 ),
2044 (
2045 925,
2046 8,
2047 cursor_over(925, 8, 1, &live_text(0..3), Some(first)),
2048 2,
2049 ),
2050 (924, 7, malformed.clone(), 2),
2051 ];
2052 for (build, log, cursor, requests) in &cases {
2053 let stub = serve(serve_log).await;
2054 let plugin = plugin_for(&stub, "", "Alpha");
2055
2056 let (bytes, read, restarted) =
2057 resume_job_log(&plugin, *build, JOB_LINUX, Some(cursor))
2058 .await
2059 .unwrap();
2060
2061 assert_eq!(bytes, live_text(0..5).as_bytes(), "{cursor:?}");
2062 let attempt = if *build == 925 { 2 } else { 1 };
2063 assert_eq!(
2064 read,
2065 LogRead::replace(Some(cursor_over(
2066 *build,
2067 *log,
2068 attempt,
2069 &live_text(0..5),
2070 None
2071 ))),
2072 "{cursor:?}"
2073 );
2074 assert!(restarted, "{cursor:?}");
2075 assert_eq!(stub.requests().len(), *requests, "{cursor:?}");
2076 assert_eq!(log_requests(&stub).last(), Some(&None), "{cursor:?}");
2077 }
2078 }
2079 }
2080}