| use anyhow::{anyhow, Context, Result}; |
| use quick_xml::events::{BytesCData, BytesEnd, BytesStart}; |
| use serde_json::Value; |
| use sha1::{Digest, Sha1}; |
| use std::{ |
| collections::HashSet, |
| env, fs, |
| io::{BufRead, BufReader, Lines, Read}, |
| path::{Path, PathBuf, MAIN_SEPARATOR}, |
| process, |
| thread::sleep, |
| time::Duration, |
| }; |
| use tracing::error; |
| |
| use crate::utils::{follow::follow, split_path_inclusive}; |
| |
| #[derive(Clone, Copy, PartialEq, Eq)] |
| pub enum Mode { |
| // Upload as Buildkite's artifacts |
| Buildkite, |
| } |
| |
| /// Upload artifacts (e.g. test logs) by reading the BEP JSON file. |
| /// |
| /// The file is read in a loop until "last message" is reached, encountered consective errors. |
| pub fn upload( |
| dry: bool, |
| debug: bool, |
| build_event_json_file: Option<&Path>, |
| mode: Mode, |
| delay: Option<Duration>, |
| monitor_flaky_tests: bool, |
| ) -> Result<()> { |
| if let Some(build_event_json_file) = build_event_json_file { |
| watch_bep_json_file( |
| dry, |
| debug, |
| build_event_json_file, |
| mode, |
| delay, |
| monitor_flaky_tests, |
| )?; |
| } |
| |
| Ok(()) |
| } |
| |
| /// Follow the BEP JSON file until "last message" encounted. |
| /// |
| /// Errors encounted before "last message", e.g. |
| /// 1. Can't open/seek the file |
| /// 2. Can't decode the line into a JSON object |
| /// are propagated. |
| fn build_events(path: &Path) -> impl Iterator<Item = Result<BuildEvent>> { |
| let path = path.to_path_buf(); |
| BuildEventIter { |
| path: path.clone(), |
| lines: BufReader::new(follow(path)).lines(), |
| reached_end: false, |
| } |
| } |
| |
| struct BuildEventIter<B> { |
| path: PathBuf, |
| lines: Lines<B>, |
| reached_end: bool, |
| } |
| |
| impl<B: BufRead> Iterator for BuildEventIter<B> { |
| type Item = Result<BuildEvent>; |
| |
| fn next(&mut self) -> Option<Self::Item> { |
| if self.reached_end { |
| return None; |
| } |
| |
| if let Some(line) = self.lines.next() { |
| let build_event = |
| match line.with_context(|| format!("Failed to read {}", self.path.display())) { |
| Ok(line) => match BuildEvent::from_json_str(&line) { |
| Ok(build_event) => Ok(build_event), |
| Err(error) => Err(anyhow!("{}: {} `{}`", self.path.display(), line, error)), |
| }, |
| Err(err) => Err(anyhow!(err)), |
| }; |
| if let Ok(ref build_event) = build_event { |
| self.reached_end = build_event.is_last_message(); |
| } |
| return Some(build_event); |
| } |
| |
| None |
| } |
| } |
| |
| fn watch_bep_json_file( |
| dry: bool, |
| debug: bool, |
| build_event_json_file: &Path, |
| mode: Mode, |
| delay: Option<Duration>, |
| monitor_flaky_tests: bool, |
| ) -> Result<()> { |
| if let Some(delay) = delay { |
| sleep(delay); |
| } |
| |
| let max_retries = 5; |
| let mut retries = max_retries; |
| let mut local_exec_root = None; |
| let mut test_summaries = vec![]; |
| let mut uploader = Uploader::new(); |
| |
| 'parse: loop { |
| for build_event in build_events(build_event_json_file) { |
| match build_event { |
| Ok(build_event) => { |
| // We have made progress, reset the retry counter |
| retries = max_retries; |
| |
| if build_event.is_workspace() { |
| local_exec_root = build_event |
| .get("workspaceInfo.localExecRoot") |
| .and_then(|v| v.as_str()) |
| .map(|str| Path::new(str).to_path_buf()); |
| } else if build_event.is_test_result() { |
| let test_result = build_event.test_result(); |
| for output in test_result.test_action_outputs.iter() { |
| match output.name.as_str() { |
| "test.log" => { |
| if ["FAILED", "TIMEOUT", "FLAKY"] |
| .contains(&test_result.status.as_str()) |
| { |
| if let Err(error) = upload_test_log( |
| &mut uploader, |
| dry, |
| local_exec_root.as_ref().map(|p| p.as_path()), |
| &output.uri, |
| mode, |
| ) { |
| error!("{:?}", error); |
| } |
| } |
| } |
| "test.xml" => { |
| if !test_result.cached { |
| if let Err(error) = upload_test_xml( |
| &mut uploader, |
| dry, |
| local_exec_root.as_ref().map(|p| p.as_path()), |
| &output.uri, |
| test_result.label.as_ref(), |
| mode, |
| ) { |
| error!("{:?}", error); |
| } |
| } |
| } |
| _ => {} |
| } |
| } |
| } else if build_event.is_test_summary() { |
| let test_summary = build_event.test_summary(); |
| test_summaries.push(test_summary); |
| } |
| } |
| Err(err) => { |
| error!("{:?}", err); |
| |
| retries -= 1; |
| if retries > 0 { |
| // Continue from start |
| continue 'parse; |
| } else { |
| // Abort since we keep getting errors |
| return Err(err); |
| } |
| } |
| } |
| } |
| |
| break 'parse; |
| } |
| |
| let should_upload_bep_json_file = |
| debug || (monitor_flaky_tests && has_overall_test_status(&test_summaries, "FLAKY")); |
| if should_upload_bep_json_file { |
| if let Err(error) = upload_bep_json_file(&mut uploader, dry, build_event_json_file, mode) { |
| error!("{:?}", error); |
| } |
| } |
| |
| Ok(()) |
| } |
| |
| fn has_overall_test_status(test_summaries: &[TestSummary], status: &str) -> bool { |
| for test_log in test_summaries.iter() { |
| if test_log.overall_status == status { |
| return true; |
| } |
| } |
| |
| false |
| } |
| |
| fn upload_bep_json_file( |
| uploader: &mut Uploader, |
| dry: bool, |
| build_event_json_file: &Path, |
| mode: Mode, |
| ) -> Result<()> { |
| uploader.upload_artifact(dry, None, build_event_json_file, mode) |
| } |
| |
| fn gen_error_content(bazelci_task: &str, label: &str, name: &str, test_log: &str) -> String { |
| let mut buf = String::new(); |
| buf.push_str(&format!("BAZELCI_TASK={}\n", bazelci_task)); |
| buf.push_str(&format!("TEST_TARGET={}\n", label)); |
| buf.push_str(&format!("TEST_NAME={}\n", name)); |
| buf.push_str("\n"); |
| buf.push_str(&format!("bazel test {} --test_filter={}\n", &label, &name)); |
| buf.push_str("\n\n"); |
| buf.push_str(test_log); |
| buf |
| } |
| |
| fn parse_test_xml(path: &Path, bazelci_task: &str, label: &str) -> Result<Option<Vec<u8>>> { |
| use quick_xml::{events::Event, reader::Reader, writer::Writer}; |
| use std::io::Cursor; |
| |
| let mut writer = Writer::new(Cursor::new(Vec::new())); |
| let mut has_testcase = false; |
| let mut buf = Vec::new(); |
| let mut reader = Reader::from_file(&path)?; |
| let fallback_classname = label.replace("//", "").replace("/", ".").replace(":", "."); |
| let mut in_error_tag = false; |
| let mut error_tag_stack = 0; |
| let mut name = String::new(); |
| loop { |
| match reader.read_event_into(&mut buf)? { |
| Event::Eof => break, |
| Event::Start(tag) => { |
| if in_error_tag { |
| error_tag_stack += 1; |
| } |
| |
| let tag = match tag.name().as_ref() { |
| b"testcase" => { |
| has_testcase = true; |
| |
| let mut new_tag = BytesStart::new("testcase"); |
| let mut has_classname = false; |
| |
| // Provide a fallback classname if it is missing or empty |
| for attr in tag.attributes() { |
| let mut attr = attr?; |
| if attr.key.as_ref() == b"classname" { |
| has_classname = true; |
| if attr.value.len() == 0 { |
| attr.value = fallback_classname.clone().into_bytes().into(); |
| } |
| } else if attr.key.as_ref() == b"name" { |
| name = String::from_utf8_lossy(&attr.value).to_string(); |
| } |
| new_tag.push_attribute(attr); |
| } |
| |
| if !has_classname { |
| new_tag.push_attribute(("classname", fallback_classname.as_ref())); |
| } |
| |
| new_tag |
| } |
| b"failure" => { |
| in_error_tag = true; |
| // replace failure with error |
| let mut new_tag = BytesStart::new("error"); |
| new_tag.push_attribute(("message", "")); |
| new_tag |
| } |
| b"error" => { |
| in_error_tag = true; |
| tag |
| } |
| _ => tag, |
| }; |
| writer.write_event(Event::Start(tag))?; |
| } |
| Event::CData(mut cdata) => { |
| if in_error_tag { |
| let test_log = String::from_utf8_lossy(&*cdata); |
| let new_content = gen_error_content(bazelci_task, label, &name, &test_log); |
| cdata = BytesCData::new(new_content); |
| } |
| |
| writer.write_event(Event::CData(cdata))?; |
| } |
| Event::Text(text) => { |
| if in_error_tag { |
| let test_log = String::from_utf8_lossy(&*text); |
| let new_content = gen_error_content(bazelci_task, label, &name, &test_log); |
| let cdata = BytesCData::new(new_content); |
| writer.write_event(Event::CData(cdata))?; |
| } else { |
| writer.write_event(Event::Text(text))?; |
| } |
| } |
| Event::End(mut tag) => { |
| if in_error_tag { |
| if error_tag_stack > 0 { |
| error_tag_stack -= 1; |
| } else { |
| in_error_tag = false; |
| tag = BytesEnd::new("error"); |
| } |
| } |
| |
| writer.write_event(Event::End(tag))?; |
| } |
| e => writer.write_event(e)?, |
| } |
| } |
| |
| if !has_testcase { |
| return Ok(None); |
| } |
| |
| Ok(Some(writer.into_inner().into_inner())) |
| } |
| |
| fn execute_command(dry: bool, cwd: Option<&Path>, program: &str, args: &[&str]) -> Result<()> { |
| println!("{} {}", program, args.join(" ")); |
| |
| if dry { |
| return Ok(()); |
| } |
| |
| let mut command = process::Command::new(program); |
| if let Some(cwd) = cwd { |
| command.current_dir(cwd); |
| } |
| command.args(args); |
| |
| let status = command |
| .status() |
| .with_context(|| format!("Failed to execute command `{} {}`", program, args.join(" ")))?; |
| |
| if !status.success() { |
| return Err(anyhow!( |
| "Failed to execute command `{} {}`: exit status {}", |
| program, |
| args.join(" "), |
| status |
| )); |
| } |
| |
| Ok(()) |
| } |
| |
| type Sha1Digest = [u8; 20]; |
| |
| fn sha1_digest(buf: &[u8]) -> Result<Sha1Digest> { |
| let mut hasher = Sha1::new(); |
| hasher.update(buf); |
| let hash = hasher.finalize(); |
| Ok(hash.into()) |
| } |
| |
| struct Uploader { |
| uploaded_digests: HashSet<Sha1Digest>, |
| } |
| |
| impl Uploader { |
| pub fn new() -> Self { |
| Self { |
| uploaded_digests: HashSet::new(), |
| } |
| } |
| |
| pub fn upload_artifact( |
| &mut self, |
| dry: bool, |
| cwd: Option<&Path>, |
| artifact: &Path, |
| mode: Mode, |
| ) -> Result<()> { |
| { |
| let file = match cwd { |
| Some(cwd) => cwd.join(artifact), |
| None => PathBuf::from(artifact), |
| }; |
| let buf = match std::fs::read(&file) { |
| Ok(buf) => buf, |
| Err(err) => match err.kind() { |
| // For test |
| std::io::ErrorKind::NotFound => file.display().to_string().into_bytes(), |
| _ => anyhow::bail!(err), |
| }, |
| }; |
| let digest = sha1_digest(&buf)?; |
| if !self.uploaded_digests.insert(digest) { |
| return Ok(()); |
| } |
| } |
| |
| match mode { |
| Mode::Buildkite => self.upload_artifact_buildkite(dry, cwd, artifact), |
| } |
| } |
| |
| fn upload_artifact_buildkite( |
| &mut self, |
| dry: bool, |
| cwd: Option<&Path>, |
| artifact: &Path, |
| ) -> Result<()> { |
| let artifact = artifact.display().to_string(); |
| execute_command( |
| dry, |
| cwd, |
| "buildkite-agent", |
| &["artifact", "upload", artifact.as_str()], |
| ) |
| } |
| |
| fn upload_test_analytics( |
| &mut self, |
| dry: bool, |
| cwd: Option<&Path>, |
| token: &str, |
| bazelci_task: &str, |
| test_xml: &Path, |
| label: &str, |
| format: &str, |
| forms: &[(impl AsRef<str>, impl AsRef<str>)], |
| ) -> Result<()> { |
| let full_path = if let Some(cwd) = cwd { |
| cwd.join(test_xml) |
| } else { |
| test_xml.to_path_buf() |
| }; |
| |
| let content = parse_test_xml(&full_path, &bazelci_task, label)?; |
| if content.is_none() { |
| return Ok(()); |
| } |
| let content = content.unwrap(); |
| |
| let digest = sha1_digest(&content)?; |
| if !self.uploaded_digests.insert(digest) { |
| return Ok(()); |
| } |
| |
| println!("Uploading to Test Analytics: data={}", full_path.display()); |
| |
| let filename = full_path |
| .file_name() |
| .map(|s| s.to_string_lossy().into_owned()); |
| let data = |
| reqwest::blocking::multipart::Part::bytes(content).mime_str("application/xml")?; |
| let data = if let Some(filename) = filename { |
| data.file_name(filename) |
| } else { |
| data |
| }; |
| if !dry { |
| let client = reqwest::blocking::Client::new(); |
| let mut form = reqwest::blocking::multipart::Form::new() |
| .part("data", data) |
| .text("format", format.to_string()); |
| |
| for form_value in forms { |
| form = form.text( |
| form_value.0.as_ref().to_string(), |
| form_value.1.as_ref().to_string(), |
| ); |
| } |
| |
| let request = client |
| .post("https://analytics-api.buildkite.com/v1/uploads") |
| .header( |
| reqwest::header::AUTHORIZATION, |
| format!("Token token={}", token), |
| ) |
| .multipart(form); |
| let mut resp = request.send()?; |
| if !resp.status().is_success() { |
| let mut msg = String::new(); |
| resp.read_to_string(&mut msg)?; |
| anyhow::bail!(format!("status={}, body={}", resp.status(), msg)); |
| } |
| } |
| |
| Ok(()) |
| } |
| |
| pub fn upload_test_xml( |
| &mut self, |
| dry: bool, |
| cwd: Option<&Path>, |
| test_xml: &Path, |
| label: &str, |
| ) -> Result<()> { |
| let token = maybe_get_env("BUILDKITE_ANALYTICS_TOKEN"); |
| let build_id = maybe_get_env("BUILDKITE_BUILD_ID"); |
| let step_id = maybe_get_env("BUILDKITE_STEP_ID"); |
| let bazelci_task = maybe_get_env("BAZELCI_TASK"); |
| if token.is_none() || build_id.is_none() || step_id.is_none() || bazelci_task.is_none() { |
| return Ok(()); |
| } |
| |
| let token = token.unwrap(); |
| let build_id = build_id.unwrap(); |
| let step_id = step_id.unwrap(); |
| let bazelci_task = bazelci_task.unwrap(); |
| |
| let mut forms = vec![ |
| ("run_env[CI]", "buildkite".to_string()), |
| ("run_env[key]", format!("{}/{}", &build_id, &step_id)), |
| ("run_env[build_id]", build_id), |
| ("run_env[tags][]", bazelci_task.clone()), |
| ]; |
| |
| for (name, env) in [ |
| ("run_env[url]", "BUILDKITE_BUILD_URL"), |
| ("run_env[branch]", "BUILDKITE_BRANCH"), |
| ("run_env[commit_sha]", "BUILDKITE_COMMIT"), |
| ("run_env[number]", "BUILDKITE_BUILD_NUMBER"), |
| ("run_env[job_id]", "BUILDKITE_JOB_ID"), |
| ("run_env[message]", "BUILDKITE_MESSAGE"), |
| ] { |
| if let Some(value) = maybe_get_env(env) { |
| forms.push((name, value)); |
| } |
| } |
| |
| self.upload_test_analytics( |
| dry, |
| cwd, |
| &token, |
| &bazelci_task, |
| test_xml, |
| label, |
| "junit", |
| &forms, |
| ) |
| } |
| } |
| |
| fn maybe_get_env(key: &str) -> Option<String> { |
| std::env::var(key).ok() |
| } |
| |
| #[allow(dead_code)] |
| fn test_label_to_path(tmpdir: &Path, label: &str, attempt: i32) -> PathBuf { |
| // replace '/' and ':' with path separator |
| let path: String = label |
| .chars() |
| .map(|c| match c { |
| '/' | ':' => MAIN_SEPARATOR, |
| _ => c, |
| }) |
| .collect(); |
| let path = path.trim_start_matches(MAIN_SEPARATOR); |
| let mut path = PathBuf::from(path); |
| |
| if attempt == 0 { |
| path.push("test.log"); |
| } else { |
| path.push(format!("attempt_{}.log", attempt)); |
| } |
| |
| tmpdir.join(&path) |
| } |
| |
| #[allow(dead_code)] |
| fn make_tmpdir_path(should_create_dir_all: bool) -> Result<PathBuf> { |
| let base = env::temp_dir(); |
| loop { |
| let i: u32 = rand::random(); |
| let tmpdir = base.join(format!("bazelci-agent-{}", i)); |
| if !tmpdir.exists() { |
| if should_create_dir_all { |
| fs::create_dir_all(&tmpdir)?; |
| } |
| return Ok(tmpdir); |
| } |
| } |
| } |
| |
| fn uri_to_file_path(uri: &str) -> Result<PathBuf> { |
| const FILE_PROTOCOL: &'static str = "file://"; |
| if uri.starts_with(FILE_PROTOCOL) { |
| if let Ok(path) = url::Url::parse(uri)?.to_file_path() { |
| return Ok(path); |
| } |
| } |
| |
| Err(anyhow!("Invalid file URI: {}", uri)) |
| } |
| |
| fn resolve_artifact( |
| uri: &str, |
| local_exec_root: Option<&Path>, |
| ) -> Result<(Option<PathBuf>, PathBuf)> { |
| let path = uri_to_file_path(uri)?; |
| if let Some((first, second)) = split_path_inclusive(&path, "testlogs") { |
| return Ok((Some(first), second)); |
| } |
| let artifact = if let Some(local_exec_root) = local_exec_root { |
| if let Ok(relative_path) = path.strip_prefix(local_exec_root) { |
| relative_path |
| } else { |
| &path |
| } |
| } else { |
| &path |
| }; |
| Ok(( |
| local_exec_root.map(|p| p.to_path_buf()), |
| artifact.to_path_buf(), |
| )) |
| } |
| |
| fn upload_test_log( |
| uploader: &mut Uploader, |
| dry: bool, |
| local_exec_root: Option<&Path>, |
| test_log: &str, |
| mode: Mode, |
| ) -> Result<()> { |
| let (cwd, artifact) = resolve_artifact(test_log, local_exec_root)?; |
| return uploader.upload_artifact(dry, cwd.as_ref().map(|pb| pb.as_path()), &artifact, mode); |
| } |
| |
| fn upload_test_xml( |
| uploader: &mut Uploader, |
| dry: bool, |
| local_exec_root: Option<&Path>, |
| test_xml: &str, |
| label: &str, |
| _mode: Mode, |
| ) -> Result<()> { |
| let (cwd, artifact) = resolve_artifact(test_xml, local_exec_root)?; |
| return uploader.upload_test_xml(dry, cwd.as_ref().map(|pb| pb.as_path()), &artifact, label); |
| } |
| |
| #[derive(Debug)] |
| pub struct TestActionOutput { |
| pub name: String, |
| pub uri: String, |
| } |
| |
| #[derive(Debug)] |
| pub struct TestResult { |
| test_action_outputs: Vec<TestActionOutput>, |
| status: String, |
| cached: bool, |
| label: String, |
| } |
| |
| #[derive(Debug)] |
| pub struct TestSummary { |
| pub overall_status: String, |
| pub failed: Vec<FailedTest>, |
| } |
| |
| #[derive(Debug)] |
| pub struct FailedTest { |
| pub uri: String, |
| } |
| |
| #[derive(Debug)] |
| pub struct BuildEvent { |
| value: Value, |
| } |
| |
| impl BuildEvent { |
| pub fn from_json_str(str: &str) -> Result<Self> { |
| let value = serde_json::from_str::<Value>(str)?; |
| if !value.is_object() { |
| return Err(anyhow!("Not a JSON object")); |
| } |
| |
| Ok(Self { value }) |
| } |
| |
| pub fn is_test_summary(&self) -> bool { |
| self.get("id.testSummary").is_some() |
| } |
| |
| pub fn is_test_result(&self) -> bool { |
| self.get("id.testResult").is_some() |
| } |
| |
| pub fn is_workspace(&self) -> bool { |
| self.get("id.workspace").is_some() |
| } |
| |
| pub fn is_last_message(&self) -> bool { |
| self.get("lastMessage") |
| .and_then(|value| value.as_bool()) |
| .unwrap_or(false) |
| } |
| |
| pub fn get(&self, path: &str) -> Option<&Value> { |
| let mut value = Some(&self.value); |
| for path in path.split(".") { |
| value = value.and_then(|value| value.get(path)); |
| } |
| value |
| } |
| |
| pub fn test_result(&self) -> TestResult { |
| let label = self |
| .get("id.testResult.label") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(); |
| let test_action_outputs = self |
| .get("testResult.testActionOutput") |
| .and_then(|v| v.as_array()) |
| .map(|test_action_output| { |
| test_action_output |
| .iter() |
| .map(|entry| TestActionOutput { |
| name: entry |
| .get("name") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(), |
| uri: entry |
| .get("uri") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(), |
| }) |
| .collect() |
| }) |
| .unwrap_or(vec![]); |
| let status = self |
| .get("testResult.status") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(); |
| let cached_locally = self |
| .get("testResult.cachedLocally") |
| .and_then(|v| v.as_bool()) |
| .unwrap_or(false); |
| let cached_remotely = self |
| .get("testResult.executionInfo.cachedRemotely") |
| .and_then(|v| v.as_bool()) |
| .unwrap_or(false); |
| let cached = cached_locally || cached_remotely; |
| TestResult { |
| test_action_outputs, |
| status, |
| cached, |
| label, |
| } |
| } |
| |
| pub fn test_summary(&self) -> TestSummary { |
| let overall_status = self |
| .get("testSummary.overallStatus") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(); |
| let failed = self |
| .get("testSummary.failed") |
| .and_then(|v| v.as_array()) |
| .map(|failed| { |
| failed |
| .iter() |
| .map(|entry| FailedTest { |
| uri: entry |
| .get("uri") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("") |
| .to_string(), |
| }) |
| .collect::<Vec<_>>() |
| }) |
| .unwrap_or(vec![]); |
| |
| TestSummary { |
| overall_status, |
| failed, |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| #[test] |
| fn test_label_to_path_works() { |
| let tmpdir = std::env::temp_dir(); |
| |
| assert_eq!( |
| test_label_to_path(&tmpdir, "//:test", 0), |
| tmpdir.join("test/test.log") |
| ); |
| |
| assert_eq!( |
| test_label_to_path(&tmpdir, "//foo/bar", 0), |
| tmpdir.join("foo/bar/test.log") |
| ); |
| |
| assert_eq!( |
| test_label_to_path(&tmpdir, "//foo/bar", 1), |
| tmpdir.join("foo/bar/attempt_1.log") |
| ); |
| } |
| |
| #[cfg(target_os = "windows")] |
| #[test] |
| fn uri_to_file_path_works() { |
| assert_eq!( |
| &uri_to_file_path("file:///c:/foo/bar").unwrap(), |
| Path::new("c:/foo/bar") |
| ); |
| } |
| |
| #[cfg(not(target_os = "windows"))] |
| #[test] |
| fn uri_to_file_path_works() { |
| assert_eq!( |
| &uri_to_file_path("file:///foo/bar").unwrap(), |
| Path::new("/foo/bar") |
| ); |
| } |
| } |