1use crate::api::channel::Channel;
2use crate::api::platform::Platform;
3use crate::api::version::Version;
4use crate::api::{API_BASE_URL, Download, DownloadsByPlatform, fetch_endpoint};
5use serde::de::Error as DeError;
6use serde::{Deserialize, Serialize};
7use std::borrow::Borrow;
8use std::collections::HashMap;
9
10const LAST_KNOWN_GOOD_VERSIONS_WITH_DOWNLOADS_JSON_PATH: &str =
80 "/chrome-for-testing/last-known-good-versions-with-downloads.json";
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct Downloads {
86 pub chrome: Vec<Download>,
88
89 pub chromedriver: Vec<Download>,
91
92 #[serde(rename = "chrome-headless-shell")]
97 pub chrome_headless_shell: Vec<Download>,
98}
99
100impl Downloads {
101 #[must_use]
103 pub fn chrome_for_platform(&self, platform: Platform) -> Option<&Download> {
104 self.chrome.for_platform(platform)
105 }
106
107 #[must_use]
109 pub fn chromedriver_for_platform(&self, platform: Platform) -> Option<&Download> {
110 self.chromedriver.for_platform(platform)
111 }
112
113 #[must_use]
115 pub fn chrome_headless_shell_for_platform(&self, platform: Platform) -> Option<&Download> {
116 self.chrome_headless_shell.for_platform(platform)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct VersionInChannel {
123 pub channel: Channel,
125
126 pub version: Version,
128
129 pub revision: String,
131
132 pub downloads: Downloads,
134}
135
136fn deserialize_channels<'de, D>(
137 deserializer: D,
138) -> Result<HashMap<Channel, VersionInChannel>, D::Error>
139where
140 D: serde::Deserializer<'de>,
141{
142 let channels = HashMap::<Channel, VersionInChannel>::deserialize(deserializer)?;
143
144 for (key, value) in &channels {
145 if key != &value.channel {
146 return Err(D::Error::custom(format!(
147 "expected channels.{key}.channel to be {key}, got {}",
148 value.channel
149 )));
150 }
151 }
152
153 Ok(channels)
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct LastKnownGoodVersions {
161 #[serde(with = "time::serde::rfc3339")]
163 pub timestamp: time::OffsetDateTime,
164
165 #[serde(deserialize_with = "deserialize_channels")]
171 channels: HashMap<Channel, VersionInChannel>,
172}
173
174impl LastKnownGoodVersions {
175 pub async fn fetch(client: &reqwest::Client) -> crate::Result<Self> {
184 Self::fetch_with_base_url(client, &API_BASE_URL).await
185 }
186
187 pub async fn fetch_with_base_url(
194 client: &reqwest::Client,
195 base_url: &reqwest::Url,
196 ) -> crate::Result<LastKnownGoodVersions> {
197 fetch_endpoint::<Self>(
198 client,
199 base_url,
200 LAST_KNOWN_GOOD_VERSIONS_WITH_DOWNLOADS_JSON_PATH,
201 "LastKnownGoodVersions",
202 )
203 .await
204 }
205
206 #[must_use]
208 pub fn channel(&self, channel: impl Borrow<Channel>) -> Option<&VersionInChannel> {
209 self.channels.get(channel.borrow())
210 }
211
212 #[must_use]
214 pub fn channels(&self) -> &HashMap<Channel, VersionInChannel> {
215 &self.channels
216 }
217
218 #[must_use]
220 pub fn stable(&self) -> Option<&VersionInChannel> {
221 self.channel(Channel::Stable)
222 }
223
224 #[must_use]
226 pub fn beta(&self) -> Option<&VersionInChannel> {
227 self.channel(Channel::Beta)
228 }
229
230 #[must_use]
232 pub fn dev(&self) -> Option<&VersionInChannel> {
233 self.channel(Channel::Dev)
234 }
235
236 #[must_use]
238 pub fn canary(&self) -> Option<&VersionInChannel> {
239 self.channel(Channel::Canary)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use crate::api::Download;
246 use crate::api::channel::Channel;
247 use crate::api::last_known_good_versions::{
248 Downloads, LAST_KNOWN_GOOD_VERSIONS_WITH_DOWNLOADS_JSON_PATH, LastKnownGoodVersions,
249 VersionInChannel,
250 };
251 use crate::api::platform::Platform;
252 use crate::api::version::Version;
253 use crate::error::Error;
254 use assertr::prelude::*;
255 use std::collections::HashMap;
256 use time::macros::datetime;
257 use url::Url;
258
259 #[tokio::test]
261 async fn can_request_from_real_world_endpoint() {
262 let result = LastKnownGoodVersions::fetch(&reqwest::Client::new()).await;
263 assert_that!(result).is_ok();
264 }
265
266 #[tokio::test]
268 #[allow(clippy::too_many_lines)]
269 async fn can_query_last_known_good_versions_api_endpoint_and_deserialize_response() {
270 let mut server = mockito::Server::new_async().await;
271 let _mock = server
272 .mock("GET", LAST_KNOWN_GOOD_VERSIONS_WITH_DOWNLOADS_JSON_PATH)
273 .with_status(200)
274 .with_header("content-type", "application/json")
275 .with_body(include_str!(
276 "./../../test-data/last_known_good_versions_with_downloads_test_response.json"
277 ))
278 .create();
279
280 let url: Url = server.url().parse().unwrap();
281
282 let data = LastKnownGoodVersions::fetch_with_base_url(&reqwest::Client::new(), &url)
283 .await
284 .unwrap();
285
286 assert_that!(data).is_equal_to(LastKnownGoodVersions {
287 timestamp: datetime!(2026-04-13 08:53:52.841 UTC),
288 channels: HashMap::from([
289 (
290 Channel::Stable,
291 VersionInChannel {
292 channel: Channel::Stable,
293 version: Version { major: 147, minor: 0, patch: 7727, build: 56 },
294 revision: String::from("1596535"),
295 downloads: Downloads {
296 chrome: vec![
297 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/linux64/chrome-linux64.zip") },
298 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-arm64/chrome-mac-arm64.zip") },
299 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-x64/chrome-mac-x64.zip") },
300 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win32/chrome-win32.zip") },
301 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win64/chrome-win64.zip") },
302 ],
303 chromedriver: vec![
304 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/linux64/chromedriver-linux64.zip") },
305 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-arm64/chromedriver-mac-arm64.zip") },
306 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-x64/chromedriver-mac-x64.zip") },
307 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win32/chromedriver-win32.zip") },
308 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win64/chromedriver-win64.zip") },
309 ],
310 chrome_headless_shell: vec![
311 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/linux64/chrome-headless-shell-linux64.zip") },
312 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-arm64/chrome-headless-shell-mac-arm64.zip") },
313 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/mac-x64/chrome-headless-shell-mac-x64.zip") },
314 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win32/chrome-headless-shell-win32.zip") },
315 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/147.0.7727.56/win64/chrome-headless-shell-win64.zip") },
316 ],
317 },
318 }
319 ),
320 (Channel::Beta, VersionInChannel {
321 channel: Channel::Beta,
322 version: Version { major: 148, minor: 0, patch: 7778, build: 5 },
323 revision: String::from("1610480"),
324 downloads: Downloads {
325 chrome: vec![
326 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/linux64/chrome-linux64.zip") },
327 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-arm64/chrome-mac-arm64.zip") },
328 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-x64/chrome-mac-x64.zip") },
329 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win32/chrome-win32.zip") },
330 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win64/chrome-win64.zip") },
331 ],
332 chromedriver: vec![
333 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/linux64/chromedriver-linux64.zip") },
334 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-arm64/chromedriver-mac-arm64.zip") },
335 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-x64/chromedriver-mac-x64.zip") },
336 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win32/chromedriver-win32.zip") },
337 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win64/chromedriver-win64.zip") },
338 ],
339 chrome_headless_shell: vec![
340 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/linux64/chrome-headless-shell-linux64.zip") },
341 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-arm64/chrome-headless-shell-mac-arm64.zip") },
342 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/mac-x64/chrome-headless-shell-mac-x64.zip") },
343 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win32/chrome-headless-shell-win32.zip") },
344 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.5/win64/chrome-headless-shell-win64.zip") },
345 ],
346 },
347 }),
348 (Channel::Dev, VersionInChannel {
349 channel: Channel::Dev,
350 version: Version { major: 148, minor: 0, patch: 7766, build: 3 },
351 revision: String::from("1607787"),
352 downloads: Downloads {
353 chrome: vec![
354 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/linux64/chrome-linux64.zip") },
355 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-arm64/chrome-mac-arm64.zip") },
356 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-x64/chrome-mac-x64.zip") },
357 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win32/chrome-win32.zip") },
358 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win64/chrome-win64.zip") },
359 ],
360 chromedriver: vec![
361 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/linux64/chromedriver-linux64.zip") },
362 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-arm64/chromedriver-mac-arm64.zip") },
363 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-x64/chromedriver-mac-x64.zip") },
364 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win32/chromedriver-win32.zip") },
365 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win64/chromedriver-win64.zip") },
366 ],
367 chrome_headless_shell: vec![
368 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/linux64/chrome-headless-shell-linux64.zip") },
369 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-arm64/chrome-headless-shell-mac-arm64.zip") },
370 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/mac-x64/chrome-headless-shell-mac-x64.zip") },
371 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win32/chrome-headless-shell-win32.zip") },
372 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/148.0.7766.3/win64/chrome-headless-shell-win64.zip") },
373 ],
374 },
375 }),
376 (Channel::Canary, VersionInChannel {
377 channel: Channel::Canary,
378 version: Version { major: 149, minor: 0, patch: 7789, build: 0 },
379 revision: String::from("1613465"),
380 downloads: Downloads {
381 chrome: vec![
382 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/linux64/chrome-linux64.zip") },
383 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-arm64/chrome-mac-arm64.zip") },
384 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-x64/chrome-mac-x64.zip") },
385 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win32/chrome-win32.zip") },
386 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win64/chrome-win64.zip") },
387 ],
388 chromedriver: vec![
389 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/linux64/chromedriver-linux64.zip") },
390 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-arm64/chromedriver-mac-arm64.zip") },
391 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-x64/chromedriver-mac-x64.zip") },
392 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win32/chromedriver-win32.zip") },
393 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win64/chromedriver-win64.zip") },
394 ],
395 chrome_headless_shell: vec![
396 Download { platform: Platform::Linux64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/linux64/chrome-headless-shell-linux64.zip") },
397 Download { platform: Platform::MacArm64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-arm64/chrome-headless-shell-mac-arm64.zip") },
398 Download { platform: Platform::MacX64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/mac-x64/chrome-headless-shell-mac-x64.zip") },
399 Download { platform: Platform::Win32, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win32/chrome-headless-shell-win32.zip") },
400 Download { platform: Platform::Win64, url: String::from("https://storage.googleapis.com/chrome-for-testing-public/149.0.7789.0/win64/chrome-headless-shell-win64.zip") },
401 ],
402 },
403 }),
404 ]),
405 });
406 }
407
408 #[tokio::test]
409 async fn unsuccessful_http_status_is_reported_as_request_error() {
410 let mut server = mockito::Server::new_async().await;
411 let _mock = server
412 .mock("GET", LAST_KNOWN_GOOD_VERSIONS_WITH_DOWNLOADS_JSON_PATH)
413 .with_status(500)
414 .with_header("content-type", "application/json")
415 .with_body(include_str!(
416 "./../../test-data/last_known_good_versions_with_downloads_test_response.json"
417 ))
418 .create();
419
420 let url: Url = server.url().parse().unwrap();
421
422 let err = LastKnownGoodVersions::fetch_with_base_url(&reqwest::Client::new(), &url)
423 .await
424 .unwrap_err();
425
426 let Error::Request(request_error) = err.current_context() else {
427 panic!("expected request error, got: {:?}", err.current_context());
428 };
429
430 assert_that!(request_error.status())
431 .is_equal_to(Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR));
432 }
433
434 #[test]
435 fn deserialization_rejects_channel_mismatch() {
436 let json = include_str!(
437 "./../../test-data/last_known_good_versions_with_downloads_test_response.json"
438 )
439 .replacen(r#""channel": "Stable""#, r#""channel": "Beta""#, 1);
440
441 let result = serde_json::from_str::<LastKnownGoodVersions>(&json);
442
443 assert_that!(result)
444 .is_err()
445 .derive(|it| it.to_string())
446 .contains("expected channels.Stable.channel to be Stable, got Beta");
447 }
448
449 #[test]
450 fn deserialization_preserves_unknown_channels() {
451 let json = include_str!(
452 "./../../test-data/last_known_good_versions_with_downloads_test_response.json"
453 )
454 .replacen(r#""Canary": {"#, r#""Extended": {"#, 1)
455 .replacen(r#""channel": "Canary""#, r#""channel": "Extended""#, 1);
456
457 let data = serde_json::from_str::<LastKnownGoodVersions>(&json).unwrap();
458 let extended = Channel::Other(String::from("Extended"));
459
460 assert_that!(data.canary()).is_none();
461 assert_that!(data.channel(&extended))
462 .is_some()
463 .derive(|it| it.channel.clone())
464 .is_equal_to(extended);
465 }
466}