ferridriver 0.3.0

Browser automation in Rust with a Playwright-compatible API. Four pluggable backends: CDP pipe, CDP WebSocket, Playwright WebKit, Firefox BiDi.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! `Download` — live handle for browser-initiated downloads intercepted
//! via CDP `Browser.downloadWillBegin` + `Browser.downloadProgress` or
//! `BiDi` `browsingContext.downloadWillBegin` + `browsingContext.downloadEnd`.
//!
//! Mirrors Playwright's client-side `Download` from
//! `/tmp/playwright/packages/playwright-core/src/client/download.ts` and
//! its server-side lifecycle from
//! `/tmp/playwright/packages/playwright-core/src/server/download.ts` +
//! `/tmp/playwright/packages/playwright-core/src/server/artifact.ts`.
//!
//! Usage:
//!
//! ```ignore
//! page.on("download", Arc::new(|event| {
//!     if let PageEvent::Download(d) = event {
//!         tokio::spawn(async move {
//!             let _ = d.save_as(std::path::Path::new("/tmp/saved.bin")).await;
//!         });
//!     }
//! }));
//!
//! let download = page.wait_for_download(5_000).await?;
//! download.save_as(std::path::Path::new("/tmp/x.bin")).await?;
//! ```
//!
//! Lifecycle rules (Playwright-faithful):
//!
//! * When the browser starts writing a download, the backend's listener
//!   builds a [`Download`] and synchronously calls
//!   [`DownloadManager::did_open`] with it.
//! * If any handler claims (returns `true`), the download is delivered
//!   to user code. Terminal state (finished / failed / cancelled) flips
//!   on the shared [`tokio::sync::watch`] once the backend's progress
//!   event reports `completed` / `canceled`; [`Download::path`] and
//!   [`Download::failure`] await that state transition.
//! * If no handler claims, the download proceeds in the background
//!   (Playwright's server emits the event but does not cancel automatically);
//!   the per-page temp-dir cleanup removes the file on page drop.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

use tokio::sync::watch;

use crate::error::{FerriError, Result};
use crate::page::Page;

/// Terminal state of a download. [`DownloadStatus::Pending`] is the
/// initial state; every other variant is terminal.
#[derive(Debug, Clone)]
pub enum DownloadStatus {
  /// Download is still writing bytes — `path`/`failure` block until the
  /// watch transitions.
  Pending,
  /// Download finished successfully; the file is at the given path.
  Finished { path: PathBuf },
  /// Download failed (canceled by caller, network error, disk error).
  Failed { error: String },
}

/// Backend-supplied async canceler. The backend builds this closure
/// when it constructs the [`Download`]; calling it issues the
/// protocol-specific cancel command (CDP `Browser.cancelDownload`, `BiDi`
/// has no cancel command so the `BiDi` canceler returns typed
/// [`FerriError::Unsupported`]).
pub type DownloadCanceler = Arc<
  dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = std::result::Result<(), FerriError>> + Send>>
    + Send
    + Sync,
>;

/// Live download handle. Cheaply cloneable — every clone shares the
/// same underlying state watch. Mirrors Playwright's `Download` client
/// class.
#[derive(Clone)]
pub struct Download {
  pub(crate) inner: Arc<DownloadState>,
}

pub(crate) struct DownloadState {
  /// Weak back-reference to the owning page. [`Download::page`] upgrades
  /// the weak; returns `None` if the outer page has been dropped.
  page: std::sync::Weak<Page>,
  /// Opaque download id assigned by the protocol (CDP's `guid`, `BiDi`'s
  /// `navigation` id). Used to correlate `downloadProgress` /
  /// `downloadEnd` events back to this handle.
  guid: String,
  /// Originating URL.
  url: String,
  /// Suggested filename reported by the protocol. Mutable because `BiDi`
  /// can report the suggested name separately from the start event
  /// (matches Playwright's `filenameSuggested`).
  suggested_filename: std::sync::Mutex<String>,
  /// Directory the browser is configured to write downloads into. The
  /// backend listener sets up a per-page temp dir and passes it here so
  /// `path()` can resolve the actual file.
  downloads_dir: PathBuf,
  /// Absolute path the browser is writing / wrote to. CDP downloads
  /// land at `downloads_dir/<guid>` when `behavior: 'allowAndName'` is
  /// set without an explicit filename; `BiDi` reports the absolute path
  /// in `downloadEnd.filepath` and the backend overrides
  /// `local_path` at `report_finished` time. A `Mutex` so both backends
  /// can update the path at completion.
  local_path: std::sync::Mutex<PathBuf>,
  /// Backend-supplied async cancel hook.
  canceler: DownloadCanceler,
  /// Watch channel of the terminal state. `watch::Sender::send` is
  /// noop-idempotent once a terminal state is set.
  state_tx: watch::Sender<DownloadStatus>,
  /// Marks `delete()` as already executed so repeated calls are
  /// idempotent. Matches Playwright's `_deleted` flag.
  deleted: AtomicBool,
}

impl Download {
  /// Construct a new download handle. Called by backend download
  /// listeners; user code receives already-constructed `Download`s via
  /// the page's [`DownloadManager`] handler.
  #[must_use]
  pub fn new(
    page: &Arc<Page>,
    guid: String,
    url: String,
    suggested_filename: String,
    downloads_dir: PathBuf,
    canceler: DownloadCanceler,
  ) -> Self {
    let local_path = downloads_dir.join(&guid);
    let (tx, _) = watch::channel(DownloadStatus::Pending);
    Self {
      inner: Arc::new(DownloadState {
        page: Arc::downgrade(page),
        guid,
        url,
        suggested_filename: std::sync::Mutex::new(suggested_filename),
        downloads_dir,
        local_path: std::sync::Mutex::new(local_path),
        canceler,
        state_tx: tx,
        deleted: AtomicBool::new(false),
      }),
    }
  }

  /// Originating URL. Playwright: `download.url(): string`.
  #[must_use]
  pub fn url(&self) -> &str {
    &self.inner.url
  }

  /// Opaque protocol-level download id. Used by the backend listener to
  /// correlate progress events back to the handle.
  #[must_use]
  pub fn guid(&self) -> &str {
    &self.inner.guid
  }

  /// Server-reported suggested filename. Playwright:
  /// `download.suggestedFilename(): string`.
  #[must_use]
  pub fn suggested_filename(&self) -> String {
    self
      .inner
      .suggested_filename
      .lock()
      .map(|g| g.clone())
      .unwrap_or_default()
  }

  /// Owning page (weak). Returns `None` if the page has been dropped.
  /// Playwright: `download.page(): Page` — the Playwright type is
  /// non-nullable because TS consumers never see a dead-page case; the
  /// Rust `Weak` upgrade returns `Option` so callers can observe
  /// target-closed without panicking.
  #[must_use]
  pub fn page(&self) -> Option<Arc<Page>> {
    self.inner.page.upgrade()
  }

  /// Backend hook: `BiDi` reports the suggested filename on the initial
  /// event; CDP reports it on the will-begin event. If a backend only
  /// learns the name later, it calls this to update the handle.
  pub fn filename_suggested(&self, suggested: String) {
    if let Ok(mut g) = self.inner.suggested_filename.lock() {
      *g = suggested;
    }
  }

  /// Backend hook: called by the listener when the protocol reports a
  /// progress `completed` / `canceled` state. `error` is `None` for a
  /// clean completion. Subsequent calls are no-ops (watch coalesces).
  ///
  /// `final_path` overrides the default `<downloads_dir>/<guid>` path
  /// when the backend knows the actual landing path (`BiDi` reports it on
  /// `downloadEnd.filepath`).
  ///
  /// Uses [`tokio::sync::watch::Sender::send_replace`] rather than
  /// `send` so the state update lands even when no receiver is
  /// currently subscribed — `send` silently discards the value when
  /// `receiver_count() == 0`, which would cause any later `path()` /
  /// `failure()` caller (who subscribes lazily) to hang on an
  /// already-resolved-but-discarded terminal transition. This is a
  /// real race: the backend's progress event can arrive before
  /// anything calls `path()` on a download dispatched via
  /// `page.on("download", ...)`.
  pub fn report_finished(&self, final_path: Option<PathBuf>, error: Option<String>) {
    if let Some(p) = final_path {
      if let Ok(mut g) = self.inner.local_path.lock() {
        *g = p;
      }
    }
    let path = self
      .inner
      .local_path
      .lock()
      .map_or_else(|_| self.inner.downloads_dir.clone(), |g| g.clone());
    let new_state = match error {
      None => DownloadStatus::Finished { path },
      Some(e) => DownloadStatus::Failed { error: e },
    };
    self.inner.state_tx.send_replace(new_state);
  }

  /// Block until the download reaches a terminal state.
  async fn wait_finished(&self) -> DownloadStatus {
    let mut rx = self.inner.state_tx.subscribe();
    loop {
      {
        let state = rx.borrow_and_update().clone();
        if !matches!(state, DownloadStatus::Pending) {
          return state;
        }
      }
      if rx.changed().await.is_err() {
        return rx.borrow().clone();
      }
    }
  }

  /// Local filesystem path the browser wrote to. Playwright:
  /// `download.path(): Promise<string>`. Blocks until the download
  /// finishes; surfaces the failure as [`FerriError::Backend`] if the
  /// download failed (mirrors Playwright's `throw this._failureErrorValue`).
  ///
  /// # Errors
  ///
  /// Returns [`FerriError::Backend`] when the download failed or was
  /// canceled.
  pub async fn path(&self) -> Result<PathBuf> {
    match self.wait_finished().await {
      DownloadStatus::Pending => Err(FerriError::Backend(
        "download watch closed before reaching terminal state".into(),
      )),
      DownloadStatus::Finished { path } => Ok(path),
      DownloadStatus::Failed { error } => Err(FerriError::Backend(error)),
    }
  }

  /// Download failure message, or `None` for a clean completion.
  /// Playwright: `download.failure(): Promise<string | null>`. Blocks
  /// until the download finishes.
  pub async fn failure(&self) -> Option<String> {
    match self.wait_finished().await {
      DownloadStatus::Failed { error } => Some(error),
      _ => None,
    }
  }

  /// Copy the downloaded file to `target`. Playwright:
  /// `download.saveAs(path): Promise<void>`. Blocks until the download
  /// finishes, then copies the bytes; creates missing parent
  /// directories to match Playwright's behaviour.
  ///
  /// # Errors
  ///
  /// Returns [`FerriError::Backend`] if the download failed, or a
  /// filesystem error if the copy fails.
  pub async fn save_as(&self, target: &Path) -> Result<()> {
    let src = self.path().await?;
    if let Some(parent) = target.parent() {
      if !parent.as_os_str().is_empty() {
        tokio::fs::create_dir_all(parent).await?;
      }
    }
    tokio::fs::copy(&src, target).await?;
    Ok(())
  }

  /// Open a read stream over the downloaded file. Playwright:
  /// `download.createReadStream(): Promise<Readable>`. Returns a
  /// [`tokio::fs::File`] — use as an `AsyncRead` or pass to `BufReader`.
  ///
  /// # Errors
  ///
  /// Returns [`FerriError::Backend`] if the download failed, or a
  /// filesystem error if opening the file fails.
  pub async fn create_read_stream(&self) -> Result<tokio::fs::File> {
    let path = self.path().await?;
    Ok(tokio::fs::File::open(path).await?)
  }

  /// Cancel a still-in-flight download. Playwright:
  /// `download.cancel(): Promise<void>`. Forwards to the backend's
  /// cancel hook; on backends without a native cancel (`BiDi`) returns
  /// typed [`FerriError::Unsupported`].
  ///
  /// # Errors
  ///
  /// See above.
  pub async fn cancel(&self) -> Result<()> {
    (self.inner.canceler)().await
  }

  /// Delete the downloaded file. Playwright:
  /// `download.delete(): Promise<void>`. Blocks until the download
  /// finishes, then unlinks. Idempotent — repeated calls are no-ops.
  ///
  /// # Errors
  ///
  /// Returns a filesystem error if the unlink fails for a reason other
  /// than "file does not exist".
  pub async fn delete(&self) -> Result<()> {
    if self.inner.deleted.swap(true, Ordering::AcqRel) {
      return Ok(());
    }
    // Wait for the download to finish so we unlink the file the browser
    // actually wrote (matches Playwright's `_delete` which awaits
    // `localPathAfterFinished`).
    let _ = self.wait_finished().await;
    let path = self
      .inner
      .local_path
      .lock()
      .map_or_else(|_| self.inner.downloads_dir.clone(), |g| g.clone());
    match tokio::fs::remove_file(&path).await {
      Ok(()) => Ok(()),
      Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
      Err(e) => Err(FerriError::from(e)),
    }
  }
}

impl std::fmt::Debug for Download {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    f.debug_struct("Download")
      .field("guid", &self.inner.guid)
      .field("url", &self.inner.url)
      .field("suggested_filename", &self.suggested_filename())
      .finish()
  }
}

// ── DownloadManager ────────────────────────────────────────────────────

/// Opaque id returned by [`DownloadManager::add_handler`] and consumed
/// by [`DownloadManager::remove_handler`]. Monotonically increasing per
/// manager.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DownloadHandlerId(pub u64);

/// Synchronous download ownership predicate. Mirrors the
/// [`crate::file_chooser::FileChooserHandlerFn`] shape: a handler is
/// called with the live download and returns `true` if it claims the
/// download (will drive `save_as` / `cancel` / `delete` eventually), or
/// `false` to pass.
pub type DownloadHandlerFn = Arc<dyn Fn(&Download) -> bool + Send + Sync>;

struct DownloadHandlerEntry {
  id: u64,
  handler: DownloadHandlerFn,
}

/// Per-page download handler registry. Mirrors
/// [`crate::file_chooser::FileChooserManager`] verbatim — same
/// "backend emits a one-shot event, at most one handler claims it"
/// semantics.
///
/// Unlike `FileChooser`, an unclaimed download is **not** auto-cancelled:
/// Playwright's server only emits `Page.Events.Download` and leaves the
/// bytes in `downloadsPath` for the caller (or for the per-context
/// cleanup on close). The per-page temp-dir drop handles eventual
/// orphans so tests don't leak files across runs.
#[derive(Clone, Default)]
pub struct DownloadManager {
  inner: Arc<DownloadManagerState>,
}

#[derive(Default)]
struct DownloadManagerState {
  handlers: std::sync::Mutex<Vec<DownloadHandlerEntry>>,
  next_id: AtomicU64,
  /// All downloads dispatched through this manager. The backend needs
  /// to look up the handle by `guid` when a `downloadProgress` event
  /// arrives; keeping them here (as weak-ish clones — the `Download`
  /// itself is cheap `Arc`-cloned) lets the listener call
  /// `report_finished` without threading a separate map through the
  /// spawn.
  ///
  /// Entries are removed by
  /// [`DownloadManager::take_for_guid`] — called by the listener on a
  /// terminal progress event.
  by_guid: std::sync::Mutex<Vec<Download>>,
}

impl DownloadManager {
  #[must_use]
  pub fn new() -> Self {
    Self::default()
  }

  /// Register a download handler. Returns a [`DownloadHandlerId`] for
  /// later removal via [`Self::remove_handler`].
  pub fn add_handler(&self, handler: DownloadHandlerFn) -> DownloadHandlerId {
    let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
    if let Ok(mut handlers) = self.inner.handlers.lock() {
      handlers.push(DownloadHandlerEntry { id, handler });
    }
    DownloadHandlerId(id)
  }

  /// Remove a previously-registered download handler.
  pub fn remove_handler(&self, id: DownloadHandlerId) {
    if let Ok(mut handlers) = self.inner.handlers.lock() {
      handlers.retain(|h| h.id != id.0);
    }
  }

  /// Called by the backend when a download opens. Iterates every
  /// registered handler synchronously and asks each "do you claim this
  /// download?". Unlike `FileChooserManager`, the unclaimed branch is a
  /// no-op (Playwright does not auto-cancel).
  pub fn did_open(&self, download: &Download) {
    if let Ok(mut by_guid) = self.inner.by_guid.lock() {
      by_guid.push(download.clone());
    }
    self.fire_download_event(download);
  }

  /// Register a download internally without firing the Download event
  /// to JS listeners. Used by backends that report `suggestedFilename`
  /// in a separate event after `downloadCreated` (PW `WebKit`) — the
  /// listener calls this on `downloadCreated`, sets the filename on
  /// `downloadFilenameSuggested`, then invokes [`Self::fire_download_event`].
  /// Mirrors Playwright's `server/download.ts` which only fires the
  /// `Page.Events.Download` event once the suggested filename is known.
  pub fn register_pending(&self, download: &Download) {
    if let Ok(mut by_guid) = self.inner.by_guid.lock() {
      by_guid.push(download.clone());
    }
  }

  /// Fire the Download event to every registered handler. Separated
  /// from [`Self::did_open`] so backends that defer the JS-side event
  /// until `suggestedFilename` arrives can register on
  /// `downloadCreated` and emit on `downloadFilenameSuggested`.
  pub fn fire_download_event(&self, download: &Download) {
    let handlers: Vec<DownloadHandlerFn> = match self.inner.handlers.lock() {
      Ok(g) => g.iter().map(|e| Arc::clone(&e.handler)).collect(),
      Err(_) => Vec::new(),
    };
    for h in handlers {
      let _ = h(download);
    }
  }

  /// Look up + remove a download by its protocol-level id. Called by
  /// the backend listener on a terminal progress event.
  #[must_use]
  pub fn take_for_guid(&self, guid: &str) -> Option<Download> {
    let mut guard = self.inner.by_guid.lock().ok()?;
    let idx = guard.iter().position(|d| d.guid() == guid)?;
    Some(guard.remove(idx))
  }

  /// Peek at a download without removing it. Used by backends that
  /// report `filenameSuggested` as a separate event before the final
  /// `downloadEnd`.
  #[must_use]
  pub fn peek_for_guid(&self, guid: &str) -> Option<Download> {
    let guard = self.inner.by_guid.lock().ok()?;
    guard.iter().find(|d| d.guid() == guid).cloned()
  }

  /// Register the default emitter-bridge handler: every page installs
  /// one at `attach_listeners` time so `page.events().on("download", cb)`
  /// delivers live [`Download`] handles on the broadcast. See
  /// [`crate::file_chooser::FileChooserManager::register_emitter_bridge`]
  /// for the underlying rationale.
  #[must_use]
  pub fn register_emitter_bridge(&self, events: crate::events::EventEmitter) -> DownloadHandlerId {
    self.add_handler(Arc::new(move |download: &Download| {
      if events.has_listener("download") {
        events.emit(crate::events::PageEvent::Download(download.clone()));
        true
      } else {
        false
      }
    }))
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  fn noop_canceler() -> DownloadCanceler {
    Arc::new(|| Box::pin(async { Ok(()) }))
  }

  #[test]
  fn download_manager_add_remove_roundtrip() {
    let mgr = DownloadManager::new();
    let fired = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let fired_c = fired.clone();
    let id = mgr.add_handler(Arc::new(move |_| {
      fired_c.fetch_add(1, Ordering::Relaxed);
      true
    }));

    // DownloadManager can't build a Download without a real Page —
    // emulate a manager-scoped dispatch by hand.
    let page = std::sync::Weak::<Page>::new();
    let (tx, _) = watch::channel(DownloadStatus::Pending);
    let d = Download {
      inner: Arc::new(DownloadState {
        page,
        guid: "abc".into(),
        url: "http://x/".into(),
        suggested_filename: std::sync::Mutex::new("f".into()),
        downloads_dir: PathBuf::from("/tmp"),
        local_path: std::sync::Mutex::new(PathBuf::from("/tmp/abc")),
        canceler: noop_canceler(),
        state_tx: tx,
        deleted: AtomicBool::new(false),
      }),
    };
    mgr.did_open(&d);
    assert_eq!(fired.load(Ordering::Relaxed), 1);

    mgr.remove_handler(id);
    mgr.did_open(&d);
    assert_eq!(fired.load(Ordering::Relaxed), 1);
  }

  #[tokio::test]
  async fn report_finished_resolves_path() {
    let page = std::sync::Weak::<Page>::new();
    let (tx, _) = watch::channel(DownloadStatus::Pending);
    let d = Download {
      inner: Arc::new(DownloadState {
        page,
        guid: "abc".into(),
        url: "http://x/".into(),
        suggested_filename: std::sync::Mutex::new("f".into()),
        downloads_dir: PathBuf::from("/tmp"),
        local_path: std::sync::Mutex::new(PathBuf::from("/tmp/abc")),
        canceler: noop_canceler(),
        state_tx: tx,
        deleted: AtomicBool::new(false),
      }),
    };
    let d2 = d.clone();
    let task = tokio::spawn(async move { d2.path().await });
    d.report_finished(Some(PathBuf::from("/tmp/final")), None);
    let p = task.await.unwrap().unwrap();
    assert_eq!(p, PathBuf::from("/tmp/final"));
  }

  #[tokio::test]
  async fn report_finished_with_error_surfaces_failure() {
    let page = std::sync::Weak::<Page>::new();
    let (tx, _) = watch::channel(DownloadStatus::Pending);
    let d = Download {
      inner: Arc::new(DownloadState {
        page,
        guid: "abc".into(),
        url: "http://x/".into(),
        suggested_filename: std::sync::Mutex::new("f".into()),
        downloads_dir: PathBuf::from("/tmp"),
        local_path: std::sync::Mutex::new(PathBuf::from("/tmp/abc")),
        canceler: noop_canceler(),
        state_tx: tx,
        deleted: AtomicBool::new(false),
      }),
    };
    let d2 = d.clone();
    let task = tokio::spawn(async move { d2.failure().await });
    d.report_finished(None, Some("canceled".into()));
    let f = task.await.unwrap();
    assert_eq!(f.as_deref(), Some("canceled"));
  }
}