QA How-To
How to Use Playwright download handling (2026)
Master Playwright download handling in TypeScript: capture events, save files, verify content, prevent CI races, and debug failed browser downloads safely.
24 min read | 3,574 words
TL;DR
For Playwright download handling, create the download event promise before clicking, await the Download object, save it under testInfo.outputPath, and validate the actual bytes. Use saveAs for remote-browser portability and avoid shared paths or fixed sleeps.
Key Takeaways
- Arm page.waitForEvent('download') before the action that initiates the file.
- Use download.saveAs with testInfo.outputPath for portable, parallel-safe storage.
- Assert the proposed filename, transfer success, format signature, and stable business content.
- Prefer awaited one-download flows over untracked page.on callbacks.
- Use createReadStream for hashing or incremental validation and saveAs for path-based tools.
- Remember that browser-context temporary downloads disappear when the context closes.
- Treat CI retention, remote browsers, retries, and sensitive data as explicit design concerns.
Playwright download handling works by arming a download event before the user action, receiving a Download object, saving the file to a controlled path, and validating the artifact. The reliable sequence is wait, trigger, capture, persist, and assert. It avoids guessing browser file locations or waiting with fixed delays.
Downloads start in a browser-context-managed temporary directory. Those temporary files are removed when the context closes, so a test that needs the artifact later should call download.saveAs() before fixture teardown. For most end-to-end tests, use testInfo.outputPath() to keep each worker's files isolated inside that test's output directory.
This 2026 guide uses Playwright Test with TypeScript and current Download APIs. It covers synchronization, filenames, content assertions, failure detection, parallel execution, remote browsers, cleanup, and CI artifacts.
TL;DR
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
test('downloads and validates a CSV export', async ({ page }, testInfo) => {
await page.goto('/reports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('orders.csv');
const savedPath = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(savedPath);
expect(await download.failure()).toBeNull();
const csv = await readFile(savedPath, 'utf8');
expect(csv).toContain('order_id,total,status');
});
| Need | API or technique |
|---|---|
| Catch one user-triggered file | page.waitForEvent('download') |
| Get the browser's proposed name | download.suggestedFilename() |
| Persist to a known location | download.saveAs(path) |
| Inspect the source URL | download.url() |
| Detect a failed transfer | download.failure() |
| Read without a second copy | download.createReadStream() |
| Stop an in-progress download | download.cancel() |
| Remove its temporary file | download.delete() |
1. How Playwright Download Handling Works
A Download object is emitted when an attachment starts downloading. The event does not mean every byte is already available. Methods such as saveAs(), path(), failure(), delete(), and createReadStream() wait for completion when they need the finished payload.
The lifecycle has four boundaries:
- The test arms page.waitForEvent('download').
- A user action or page behavior initiates the transfer.
- Playwright emits Download as the transfer starts.
- The test saves or reads the completed payload and validates it.
All downloads produced by a browser context live in temporary storage and are deleted when that context closes. The built-in page fixture closes its context after each test. Therefore, an unawaited page.on('download') callback can outlive the test's main control flow and lose its file during teardown. A direct awaited flow is clearer.
The filename from download.suggestedFilename() is derived from browser logic, often using Content-Disposition or an anchor's download attribute. It is a suggestion, not a secure destination path. Use it as a basename, never concatenate untrusted directory segments from application content.
Playwright's download event applies to browser downloads, including attachment responses and download links. It is not the same as an APIRequestContext response. If the user experience itself matters, drive the browser and capture the event. If only the file-producing endpoint matters, an API test may be faster, but it will not prove the UI initiated the right download.
For a catalog of focused variants, see Playwright download handling examples.
2. Install and Configure a Download Test Project
Create a Playwright Test project:
npm init playwright@latest
npx playwright install
A practical configuration enables useful artifacts without defining a shared download directory:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
outputDir: 'test-results',
timeout: 30_000,
expect: {
timeout: 5_000,
},
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
Playwright Test gives every test a testInfo object. testInfo.outputPath(name) resolves a unique path under that test's result directory. This matters when workers run in parallel or when retries execute concurrently with other tests. A fixed location such as ./downloads/report.csv can cause tests to overwrite each other.
Do not commit generated downloads. Test output belongs in the configured output directory, which is usually already excluded from version control. If a pipeline must retain a file, attach it explicitly or upload the relevant result directory through the CI system after the test finishes.
Use accessible locators for the trigger. An export control might be a button, link, or menu item. If you are modernizing selector strategy, Playwright getByRole locators provides practical patterns.
The rest of this guide assumes the download begins after a click. The same event-ordering rule applies when a keyboard action, form submission, popup, or application callback initiates it.
3. Capture the Event Before Clicking
The canonical pattern intentionally creates the promise without awaiting it:
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download invoice' }).click();
const download = await downloadPromise;
If you await the first line immediately, the test pauses before it ever clicks, so no download can begin. If you create the promise after clicking, a fast download may emit the event before the listener exists. Arm first, trigger second, await third.
A complete smoke test can validate both the source and the proposed name:
import { test, expect } from '@playwright/test';
test('starts the selected invoice download', async ({ page }) => {
await page.goto('/invoices/INV-1042');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('invoice-INV-1042.pdf');
expect(download.url()).toContain('/api/invoices/INV-1042/pdf');
expect(await download.failure()).toBeNull();
});
This test proves initiation and successful completion, but it does not persist the file or validate content. Use it only when that level matches the risk. Financial, compliance, and customer-facing exports deserve content assertions.
You may see Promise.all used for event and action:
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export' }).click(),
]);
It is valid, but the named-promise form is often easier to debug and extend. Both register the event wait before the triggering action starts. Consistency matters more than preference.
4. Save Downloads to a Worker-Safe Path
download.saveAs(destination) copies the payload to a caller-controlled path. It is safe to call while the transfer is still running because Playwright waits for completion.
import { test, expect } from '@playwright/test';
import { stat } from 'node:fs/promises';
test('saves an invoice in the test output directory', async ({ page }, testInfo) => {
await page.goto('/invoices/INV-1042');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download PDF' }).click();
const download = await downloadPromise;
const destination = testInfo.outputPath('invoice.pdf');
await download.saveAs(destination);
const file = await stat(destination);
expect(file.isFile()).toBe(true);
expect(file.size).toBeGreaterThan(0);
});
A nonzero size is only a basic transport check. A server can return a nonempty HTML error page with a .pdf filename. Later sections show format and semantic validation.
Choose the destination name deliberately. Keeping suggestedFilename preserves user-facing naming behavior. Choosing a stable local name makes subsequent tooling simpler. You can do both: assert the suggestion, then save under a deterministic test path.
expect(download.suggestedFilename()).toBe('invoice-INV-1042.pdf');
await download.saveAs(testInfo.outputPath('actual-invoice.pdf'));
Do not save all workers to a shared absolute directory unless each filename includes a collision-proof identifier and cleanup is managed. Per-test output paths already solve most of that problem.
saveAs can be called more than once when separate copies are truly necessary, but repeated copying increases I/O. Usually one saved artifact is enough. Read it, parse it, attach it if needed, and let the test runner clean its output based on your normal retention policy.
5. Validate Filenames, Extensions, and MIME Clues
Filename assertions catch regressions in Content-Disposition, selected report type, user-visible naming, and unsafe extension changes. suggestedFilename returns only the browser's proposed filename.
const filename = download.suggestedFilename();
expect(filename).toMatch(/^orders-\d{4}-\d{2}-\d{2}\.csv$/);
expect(filename.endsWith('.csv')).toBe(true);
expect(filename).not.toContain('/');
expect(filename).not.toContain('\\');
The regular expression should express a real contract. If the product intentionally localizes or timestamps names, assert the stable structure rather than a hard-coded date. Avoid patterns so broad that report.html could pass a CSV test.
A browser download does not expose a dedicated Download MIME type property. You can use page.waitForResponse with a specific predicate when response headers are part of the requirement, but coordinate it carefully with the same action:
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/reports/orders') &&
response.request().method() === 'GET',
);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const [response, download] = await Promise.all([
responsePromise,
downloadPromise,
]);
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/csv');
expect(download.suggestedFilename()).toMatch(/\.csv$/);
Only add response coupling when it provides value. Redirects, signed URLs, and service workers can make an over-specific response predicate fragile. The downloaded bytes are the ultimate artifact, so content validation is stronger than a header alone.
Treat suggested filenames as untrusted input if your test framework reuses them in external commands. testInfo.outputPath safely scopes the path, but do not pass a filename into a shell string. Node file APIs accept paths directly and avoid shell injection.
6. Assert CSV, JSON, Text, and Binary Content
Download tests should validate the smallest meaningful contract. For CSV, check headers, a known business record, delimiter behavior, and encoding when those are important.
import { readFile } from 'node:fs/promises';
const destination = testInfo.outputPath('orders.csv');
await download.saveAs(destination);
const csv = await readFile(destination, 'utf8');
const lines = csv.trim().split(/\r?\n/);
expect(lines[0]).toBe('order_id,total,status');
expect(lines).toContain('ORD-1042,25.00,PAID');
expect(lines).toHaveLength(3);
Simple splitting is acceptable only for controlled CSV without quoted commas, embedded newlines, or escaped quotes. Production exports with full CSV semantics should use a maintained parser already approved by the project. Do not build a partial parser inside a test suite.
JSON can use the standard library:
const raw = await readFile(destination, 'utf8');
const data: unknown = JSON.parse(raw);
expect(data).toEqual({
generatedFor: 'qa@example.com',
items: [
{ id: 'ORD-1042', total: 25, status: 'PAID' },
],
});
For a PDF, verify the signature before handing the file to a PDF parsing library:
const bytes = await readFile(destination);
expect(bytes.subarray(0, 5).toString('ascii')).toBe('%PDF-');
The signature catches HTML error pages saved with a PDF extension. Text extraction, page count, metadata, and visual comparison require a PDF-aware library or service. Pick assertions based on business risk. A basic export smoke test might check signature and size, while a regulated statement test should parse key values and page structure.
Avoid snapshotting entire volatile files. Generated timestamps, object ordering, document IDs, and metadata can cause noisy failures. Parse the format and compare stable, user-relevant fields.
7. Use createReadStream for Stream-Oriented Validation
download.createReadStream() returns a Node readable stream for a successful download and throws for a failed or canceled transfer. It is useful when you want to hash, parse, or inspect data without making another saved copy.
This helper counts bytes and computes a SHA-256 digest:
import { createHash } from 'node:crypto';
import type { Download } from '@playwright/test';
async function digestDownload(download: Download): Promise<{
bytes: number;
sha256: string;
}> {
const stream = await download.createReadStream();
const hash = createHash('sha256');
let bytes = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
hash.update(buffer);
}
return { bytes, sha256: hash.digest('hex') };
}
Use it in a test:
const result = await digestDownload(download);
expect(result.bytes).toBeGreaterThan(100);
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
A digest is meaningful when compared with an expected value for deterministic fixtures. Merely checking that it has 64 hex characters proves the helper ran, not that content is correct. For dynamic files, parse and assert semantic values instead.
Streaming is helpful for large exports because it avoids loading the entire payload into memory. Still, end-to-end test data should remain intentionally sized. A browser suite is rarely the right place for load testing multi-gigabyte exports.
If downstream tools require a filesystem path, saveAs is simpler. Choose streaming for a stream-native validator, not because it looks more advanced.
8. Detect Failures, Cancellations, and Empty Artifacts
download.failure() resolves to null for a successful transfer or a string describing failure. It waits for completion when necessary. Check it when failure reporting itself is part of the test or before processing uncertain content.
const failure = await download.failure();
expect(failure).toBeNull();
To exercise cancellation behavior, capture the Download and call cancel():
test('can cancel a long-running export', async ({ page }) => {
await page.goto('/large-export');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Start export' }).click();
const download = await downloadPromise;
await download.cancel();
expect(await download.failure()).toBe('canceled');
});
Cancellation tests require a server response that remains active long enough for the call to matter. A tiny fixture may finish before cancellation, although cancel() itself does not fail if the download already finished. Design the test endpoint deterministically instead of depending on production file size or network slowness.
An application-level export failure might not emit a download at all. It may display an error toast or return a normal HTML page. Test that behavior with a UI assertion and, if useful, a response assertion. Do not wait for a download event when the expected outcome is explicitly no download unless you use a short, carefully justified event timeout. Usually the visible error is the better oracle.
Check empty files after saving or streaming. Zero bytes can be a valid format only when the product contract says so. More often it signals a backend error, authorization issue, or race in export generation.
For general timeout diagnosis, use the Playwright 30000ms timeout guide instead of masking a missing event with a larger number.
9. Clean Up Temporary and Saved Files
download.delete() removes the context-managed downloaded file and waits for the transfer to finish if needed. It does not mean every copy made with saveAs is removed. Saved copies are normal filesystem files managed by your test output policy.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export' }).click();
const download = await downloadPromise;
await download.saveAs(testInfo.outputPath('export.csv'));
await download.delete();
Most Playwright Test cases do not need to call delete because context teardown removes the temporary file automatically. Explicit deletion is useful in a long-lived context, a test that creates many large artifacts, or a privacy-sensitive workflow where temporary bytes should disappear as soon as validation ends.
Do not delete before readers finish. saveAs waits for its copy, but a createReadStream consumer must complete or close before cleanup. Keep ownership linear: capture, validate, optionally attach, then delete.
If you create extra directories outside testInfo.outputPath, remove them with Node filesystem APIs in a finally block. Avoid a broad cleanup command that can target the wrong path. Prefer unique temporary directories and narrow rm calls.
In CI, cleanup and retention are separate decisions. The runner can delete browser temporary data while the pipeline retains selected test artifacts. Attach only useful failures or sampled outputs, because indiscriminate retention can expose customer data and consume storage.
10. Scale Playwright Download Handling Across Parallel Tests
Parallel safety begins with unique state. Each test should use a unique application record, a unique output path, and an independent browser context. Do not let workers request the same asynchronous export job if the backend permits only one active job per account.
A reusable fixture can provide a deterministic save helper:
// tests/fixtures.ts
import { test as base, type Download } from '@playwright/test';
type DownloadFixtures = {
saveDownload: (download: Download, name?: string) => Promise<string>;
};
export const test = base.extend<DownloadFixtures>({
saveDownload: async ({}, use, testInfo) => {
await use(async (download, name) => {
const destination = testInfo.outputPath(
name ?? download.suggestedFilename(),
);
await download.saveAs(destination);
return destination;
});
},
});
export { expect } from '@playwright/test';
The fixture centralizes output isolation but leaves business assertions in the test:
import { test, expect } from './fixtures';
import { readFile } from 'node:fs/promises';
test('exports the worker-owned account', async ({
page,
saveDownload,
}) => {
await page.goto('/account/export');
const event = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export account' }).click();
const path = await saveDownload(await event, 'account.json');
const data = JSON.parse(await readFile(path, 'utf8'));
expect(data.account.email).toContain('@example.test');
});
Retries also need deterministic backend setup. If the first attempt starts a long-running export and then fails, the retry may encounter an existing job. Reset state through an API fixture or generate worker-specific data.
A shared page.on('download') collector is usually the wrong abstraction. It forks control flow, makes completion hard to await, and can associate a file with the wrong test step. Capture the specific event next to its trigger.
11. Run and Diagnose Downloads in CI or Remote Browsers
Run the focused suite across configured projects:
npx playwright test tests/downloads.spec.ts
npx playwright test tests/downloads.spec.ts --project=chromium --trace=on
When CI fails, classify the symptom:
| Symptom | Likely area to inspect |
|---|---|
| Timeout waiting for download | Trigger, permissions, route, feature flag |
| Wrong suggested filename | Content-Disposition or link download attribute |
| saveAs rejects | Transfer failure, invalid destination, teardown race |
| File contains HTML | Authentication, error response, redirect |
| Local pass, CI empty file | Backend job timing, test data, proxy behavior |
| Workers overwrite files | Shared destination path |
| path() unavailable remotely | Client and browser run on different machines |
download.path() gives the temporary path after completion, but it throws when Playwright is connected to a remote browser. saveAs() transfers the file to the test runner's requested destination and is the more portable choice. createReadStream() is also useful when the runner needs bytes directly.
Trace Viewer helps confirm the click, navigation, request activity, and page state. It does not replace file inspection. On failure, consider attaching the saved artifact:
await testInfo.attach('downloaded-export', {
path: destination,
contentType: 'text/csv',
});
Attachments are copied to a location accessible to reporters, so attach before the test ends. Check organizational rules before retaining files with personal, financial, or production data.
Pipeline design for browser artifacts is covered in Jenkins pipelines for Playwright. Keep secrets out of downloaded fixtures, use synthetic accounts, and set an explicit retention period.
Interview Questions and Answers
Q: What is the reliable sequence for a Playwright download test?
Create page.waitForEvent('download') before the action, perform the action, await the event promise, save or stream the file, and assert its business content. This sequence prevents races and keeps the transfer in the test's main control flow.
Q: Why should you prefer saveAs over path in portable test suites?
download.path() points to browser-managed temporary storage and is unavailable for remote browser connections. saveAs copies the completed artifact to a destination controlled by the test runner. It also makes ownership and CI attachment straightforward.
Q: What does suggestedFilename guarantee?
It reports the browser's proposed filename, often derived from Content-Disposition or a download attribute. It does not prove content type or correctness, and it should not be trusted as an arbitrary filesystem path. Assert it as a user-facing contract, then validate bytes.
Q: How do you prevent collisions in parallel download tests?
Use testInfo.outputPath for destinations, independent contexts, and worker-specific application data. Avoid a shared ./downloads filename. Also make retries idempotent so an earlier export job cannot corrupt a later attempt.
Q: How do you validate a downloaded file beyond its extension?
Read or stream the bytes and assert format-specific structure and stable business values. For example, parse JSON, use a real CSV parser, or verify a PDF signature and extract required fields with a PDF library. A filename and nonzero size are only transport checks.
Q: When would you use createReadStream?
Use it when validation is naturally stream-based, such as hashing, incremental parsing, or checking a large file without loading it entirely into memory. Use saveAs when libraries expect a path or when the artifact must be attached to a report.
Q: What happens to downloads after a browser context closes?
Playwright deletes context-owned temporary downloads. Files copied with saveAs remain under the destination chosen by the test and follow normal filesystem or test-runner cleanup rules. Await saving before fixture teardown.
Q: How do you test a failed download?
First distinguish a transfer failure from an application error that never starts a download. For an emitted Download, inspect download.failure(). For a UI-level export failure, assert the visible error and relevant response rather than waiting for an event that should not occur.
Common Mistakes
- Awaiting page.waitForEvent('download') before triggering the download. That pauses the test before the click.
- Registering the event wait after clicking. A fast event can be missed.
- Assuming the event means the file is complete. Use saveAs, path, failure, or createReadStream to await the payload.
- Saving every worker to the same filename. Use testInfo.outputPath or another unique destination.
- Checking only the extension or size. Parse stable content and detect HTML error bodies.
- Using suggestedFilename as an unrestricted path. Treat it as untrusted browser output and keep it inside a controlled directory.
- Starting asynchronous work in page.on('download') without awaiting it. Test teardown can delete temporary files first.
- Relying on download.path() with a remote browser. Prefer saveAs or createReadStream.
- Adding fixed sleeps to wait for export generation. Synchronize on the event and observable UI or backend state.
- Parsing complex CSV with string splitting. Use a standards-compliant parser for quoted fields and embedded newlines.
- Retaining sensitive downloads in CI without a policy. Use synthetic data and explicit artifact controls.
- Forgetting retry state. A previous attempt may leave an export job or record that changes the retry's behavior.
Conclusion
Playwright download handling is reliable when the test owns the entire lifecycle: arm the event before the trigger, capture the Download object, persist it to an isolated path, and validate stable content. saveAs is the most portable persistence API, while createReadStream supports stream-based checks and failure(), cancel(), and delete() cover operational cases.
Start with one representative export, assert its suggested name and real contents, then add negative, cancellation, cross-browser, and parallel cases according to risk. Keep artifacts scoped to test output, avoid fixed sleeps, and retain files in CI only when they help diagnose a failure.
Interview Questions and Answers
Explain the correct event ordering for Playwright downloads.
I arm page.waitForEvent('download') before the action, perform the action, and then await the event promise. This prevents both waiting before the trigger and registering too late. I keep saving and validation in the same awaited control flow.
Why is download.saveAs usually preferable to download.path?
saveAs produces a test-owned file at a known destination and works when the browser is remote. path exposes browser-managed temporary storage and throws for remote connections. saveAs also integrates cleanly with test output and reporter attachments.
How would you validate a CSV download?
I assert the stable filename, save to a unique output path, check download.failure(), and parse the CSV with a compliant parser when quoting is possible. I verify headers, representative rows, field values, and encoding requirements rather than snapshotting the entire volatile artifact.
How do you make download tests safe under parallel execution?
I use testInfo.outputPath, unique application data, and per-test browser contexts. I avoid shared filenames and global download collectors. I also make setup and export creation retry-safe.
What is the lifecycle of a Playwright Download object?
It is emitted when a browser download begins. Playwright keeps its payload in browser-context temporary storage, and methods such as saveAs wait for completion. Context teardown deletes the temporary file, while explicitly saved copies remain.
When would you use download.createReadStream?
I use it for incremental parsing, hashing, or validating larger files without loading all bytes into memory or making a second copy. If a parser expects a path or the file must be attached, saveAs is simpler.
How do you diagnose a timeout waiting for a download?
I first verify that the trigger is visible and enabled, the expected user and feature flag can export, and the action reaches the intended route. I inspect the trace and responses for redirects or errors. I do not increase the timeout until I know a transfer is merely slow rather than absent.
How do you distinguish a failed download from an application export error?
A transfer that starts produces a Download whose failure() can describe failure. An application error may show a toast or error page and never emit the event. I assert the observable contract for each case instead of forcing both through the same wait.
Frequently Asked Questions
How do I wait for a file download in Playwright?
Create const downloadPromise = page.waitForEvent('download') before the triggering action. Click the control, then await downloadPromise to receive the Download object.
Where does Playwright store downloaded files?
Playwright initially stores them in a temporary directory owned by the browser context. The temporary files are removed when that context closes, so call download.saveAs if the test needs a persistent copy.
How do I save a Playwright download with its original name?
Read download.suggestedFilename() and pass a controlled destination containing that basename to download.saveAs(). testInfo.outputPath(download.suggestedFilename()) is convenient in Playwright Test.
How can I verify a downloaded file in Playwright?
Save or stream it, then use a format-appropriate parser to assert stable business content. Also check the suggested filename, download.failure(), and a format signature when useful.
Why does a download test pass locally but fail in CI?
Common causes include shared output paths, different permissions or feature flags, stale retry state, authentication redirects, and unawaited event callbacks. Inspect the saved bytes and trace, and isolate both test data and destinations.
Can Playwright read a download without saving it?
Yes. download.createReadStream() returns a Node readable stream for a successful download. It is useful for hashing or incremental parsing without making a saved copy.
What is the difference between download.path and download.saveAs?
path returns the browser-managed temporary file path and is unavailable with remote browser connections. saveAs copies the completed file to a path controlled by the test runner and is more portable.
Does the download event mean the file has finished downloading?
No. It is emitted when the transfer starts. Methods that require the completed payload, including saveAs, path, failure, delete, and createReadStream, wait as needed.