browser_commander/downloads/
attach.rs1use std::sync::Arc;
27
28use crate::core::EngineType;
29use crate::downloads::manager::DownloadManager;
30use crate::downloads::options::DownloadOptions;
31use crate::downloads::DownloadError;
32use crate::fingerprint::CdpTransport;
33
34#[derive(Debug, Clone, Default)]
40pub enum DownloadSetting {
41 #[default]
43 Off,
44 On,
46 Options(Box<DownloadOptions>),
48}
49
50impl From<bool> for DownloadSetting {
51 fn from(enabled: bool) -> Self {
52 if enabled {
53 Self::On
54 } else {
55 Self::Off
56 }
57 }
58}
59
60impl From<DownloadOptions> for DownloadSetting {
61 fn from(options: DownloadOptions) -> Self {
62 Self::Options(Box::new(options))
63 }
64}
65
66pub fn normalize_download_options(setting: DownloadSetting) -> Option<DownloadOptions> {
76 match setting {
77 DownloadSetting::Off => None,
78 DownloadSetting::On => Some(DownloadOptions::default()),
79 DownloadSetting::Options(options) => Some(*options),
80 }
81}
82
83pub fn supported_engine(engine: EngineType) -> Result<(), DownloadError> {
100 match engine {
101 EngineType::Chromiumoxide => Ok(()),
102 EngineType::Fantoccini => Err(DownloadError::Unsupported {
103 engine: engine.to_string(),
104 reason: "WebDriver has neither download events nor a way to \
105 redirect downloads; use EngineType::Chromiumoxide"
106 .to_string(),
107 }),
108 EngineType::Playwright | EngineType::Puppeteer => Err(DownloadError::Unsupported {
109 engine: engine.to_string(),
110 reason: "the node bridge speaks its own command protocol rather \
111 than CDP; use EngineType::Chromiumoxide, or manage \
112 downloads from the JavaScript package, which drives \
113 Playwright and Puppeteer directly"
114 .to_string(),
115 }),
116 }
117}
118
119pub async fn attach_downloads(
137 engine: EngineType,
138 transport: &dyn CdpTransport,
139 setting: DownloadSetting,
140) -> Result<Option<Arc<DownloadManager>>, DownloadError> {
141 let Some(options) = normalize_download_options(setting) else {
142 return Ok(None);
143 };
144 supported_engine(engine)?;
145
146 let manager = DownloadManager::create(options)?;
147 manager.attach(transport).await?;
148 Ok(Some(manager))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::downloads::test_support::{RecordingTransport, TempDir};
155
156 fn options(temp: &TempDir) -> DownloadOptions {
158 DownloadOptions::default().directory(temp.path().join("downloads").to_string_lossy())
159 }
160
161 #[test]
162 fn reads_the_three_shapes_the_other_languages_accept() {
163 assert!(normalize_download_options(DownloadSetting::Off).is_none());
164 assert!(normalize_download_options(false.into()).is_none());
165 assert!(normalize_download_options(DownloadSetting::On).is_some());
166 assert!(normalize_download_options(true.into()).is_some());
167
168 let configured = normalize_download_options(
169 DownloadOptions::default()
170 .directory("/tmp/bc-downloads")
171 .into(),
172 )
173 .expect("configured downloads");
174 assert_eq!(configured.directory.as_deref(), Some("/tmp/bc-downloads"));
175 }
176
177 #[tokio::test]
178 async fn points_the_browser_at_the_staging_directory() {
179 let temp = TempDir::new("bc-attach");
180 let transport = RecordingTransport::default();
181
182 let manager =
183 attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
184 .await
185 .expect("attach")
186 .expect("a manager");
187
188 let sent = transport.sent();
189 assert_eq!(sent.len(), 1);
190 assert_eq!(sent[0].0, "Browser.setDownloadBehavior");
191 assert_eq!(
192 sent[0].1["downloadPath"],
193 manager
194 .directory
195 .join(crate::downloads::STAGING_DIRECTORY)
196 .to_string_lossy()
197 .as_ref()
198 );
199 manager.dispose().await;
200 }
201
202 #[tokio::test]
203 async fn leaves_the_browser_alone_when_downloads_are_off() {
204 let transport = RecordingTransport::default();
205
206 let manager = attach_downloads(EngineType::Chromiumoxide, &transport, DownloadSetting::Off)
207 .await
208 .expect("attach");
209
210 assert!(manager.is_none());
211 assert!(
212 transport.sent().is_empty(),
213 "a browser with downloads off had its download behavior changed"
214 );
215 }
216
217 #[tokio::test]
218 async fn refuses_an_engine_that_cannot_manage_downloads() {
219 let temp = TempDir::new("bc-attach-unsupported");
220 let transport = RecordingTransport::default();
221
222 for engine in [
223 EngineType::Fantoccini,
224 EngineType::Playwright,
225 EngineType::Puppeteer,
226 ] {
227 let error = attach_downloads(engine, &transport, options(&temp).into())
228 .await
229 .unwrap_err();
230
231 assert!(
232 error
233 .to_string()
234 .contains("managed downloads are not supported"),
235 "unexpected message: {error}"
236 );
237 assert!(
238 error.to_string().contains(&engine.to_string()),
239 "the message does not name the engine: {error}"
240 );
241 }
242 }
243
244 #[tokio::test]
245 async fn reports_a_browser_that_refuses_to_redirect_its_downloads() {
246 let temp = TempDir::new("bc-attach-refused");
247 let transport = RecordingTransport::refusing("Browser domain is not available");
248
249 let error = attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
250 .await
251 .unwrap_err();
252
253 assert!(
254 error
255 .to_string()
256 .contains("the browser refused to redirect its downloads"),
257 "unexpected message: {error}"
258 );
259 }
260}