Skip to main content

pipedash_plugin_azure_devops/
plugin.rs

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(&params.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 } = &parameter.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}