PRACTICAL GUIDE / Playwright user interaction testing files media

Test file uploads, media previews, and downloads as one user journey

Build reliable Playwright checks that select real files, verify image and video previews, inspect downloads, and explain media-only failures in CI.

By The Testing AcademyUpdated August 4, 202621 min read
All field guides
In this guide6 sections
  1. Why upload tests lie when they stop at selection
  2. Choose the browser action that matches the UI
  3. Prove image and video workflows at useful boundaries
  4. Diagnose uploads, previews, and downloads separately
  5. Wire artifacts into CI without path collisions
  6. Know when browser automation is the wrong layer

What you will learn

  • Why upload tests lie when they stop at selection
  • Choose the browser action that matches the UI
  • Prove image and video workflows at useful boundaries
  • Diagnose uploads, previews, and downloads separately

The upload control accepts a video, shows its filename, and leaves the preview spinner running forever in CI. A test that stops after setInputFiles() reports success because file selection worked. The user still cannot review or submit the media.

That gap is the central risk in file and media automation. Selecting bytes, decoding a preview, posting a form, and downloading a processed result are separate transitions. Each can fail while the previous one remains green, so each needs evidence tied to what the user can do next.

Why upload tests lie when they stop at selection

An HTML file input exposes a FileList. Playwright's locator.setInputFiles() sets that input to one or more paths or in-memory file payloads. Relative paths are resolved from the process's current working directory, which is why a fixture found from one local script can disappear when CI launches from the repository root. Resolve fixtures from a known module location and fail if the file is not in the checkout.

The call completing tells you that Playwright selected the file. It does not tell you that the application accepted the type, decoded an image, generated a thumbnail, finished an upload, or stored anything on the server. A filename rendered beside the control is only slightly stronger. Many interfaces copy File.name into text before validation begins.

Media adds a browser-owned stage. An <img> must decode enough data to expose intrinsic dimensions. An <audio> or <video> element moves through network and readiness states, and playback can depend on format support and whether the user initiated it. Your application may add another asynchronous stage to transcode, crop, scan, or upload the bytes. One large timeout cannot explain which stage stopped.

Build assertions around boundaries the product owns. After selection, inspect the selected file metadata when it helps diagnose the fixture. Then wait for a visible accepted or rejected state. For an image preview, prove the image decoded instead of checking src alone. For a video player, click the product's Play control and observe playback state or progress. After submission, assert the server-backed result the UI presents. If the workflow returns a file, open or parse enough of that file to verify the promised transformation.

This layered oracle can fail when the product changes. If an image preview regresses to a broken element, naturalWidth remains zero. If the uploader displays success before the request finishes, a later server-backed assertion fails. If a crop export returns the original dimensions, reading the PNG header catches it. These are not assertions over a hard-coded object that always agrees with itself.

Do not confuse browser-declared MIME type with trusted file identity. An in-memory Playwright payload lets the test choose name, mimeType, and buffer. That is useful for checking client-side rules, including conflicting metadata. It also demonstrates why the server cannot trust the browser. A payload called portrait.png can declare application/pdf, and neither string proves what the bytes contain.

The test layer should match the claim. A browser rejection test can prove that the UI responds to a disallowed declared type. An API or service test should prove that backend inspection rejects hostile or inconsistent content. Security scanning, archive expansion, and object-store policy do not become covered merely because a browser submitted the file successfully.

Choose the browser action that matches the UI

Use setInputFiles() when the page has a stable <input type="file">, even if design CSS hides it behind a label. Playwright's locator may point at the input or at a label with an associated control. This route is direct and reliable. It avoids automating the operating system's native chooser, which is outside the web page.

The simplest test selects a repository fixture by absolute path and immediately records the metadata the browser received. The UI assertions then prove that the application progressed beyond selection.

TypeScript
// tests/uploads/avatar.spec.ts
import path from 'node:path';
import { test, expect } from '@playwright/test';

const avatar = path.resolve(__dirname, 'fixtures/avatar.png');

test('previews a selected avatar before saving', async ({ page }) => {
  await page.goto('/profile');

  const input = page.getByLabel('Choose profile image');
  await input.setInputFiles(avatar);

  const selected = await input.evaluate((node: HTMLInputElement) => {
    const file = node.files?.item(0);
    return {
      count: node.files?.length ?? 0,
      name: file?.name,
      type: file?.type,
    };
  });
  expect(selected).toEqual({
    count: 1,
    name: 'avatar.png',
    type: 'image/png',
  });

  const preview = page.getByRole('img', { name: 'New profile image preview' });
  await expect(preview).toBeVisible();
  await expect.poll(() => preview.evaluate((image: HTMLImageElement) => ({
    complete: image.complete,
    width: image.naturalWidth,
  }))).toEqual({ complete: true, width: 320 });

  await page.getByRole('button', { name: 'Save profile' }).click();
  await expect(page.getByRole('status')).toHaveText('Profile image saved');
});

The example's 320 pixel width is a property of its checked-in fixture, not a claimed measurement. Pinning that value detects a corrupt or accidentally replaced test asset as well as a broken preview. If the application intentionally resizes the preview element with CSS, naturalWidth still reports the decoded resource's intrinsic width. Assert rendered dimensions only when layout size is part of the user contract.

Use the file chooser event when clicking the product control creates the input dynamically or when that click path contains behavior worth testing. Start waiting before the click. If the listener is registered afterward, the event can already be gone.

TypeScript
// tests/uploads/import-dialog.spec.ts
import path from 'node:path';
import { test, expect } from '@playwright/test';

test('imports a file through a dynamically created chooser', async ({ page }) => {
  await page.goto('/library');

  const chooserPromise = page.waitForEvent('filechooser');
  await page.getByRole('button', { name: 'Import media' }).click();
  const chooser = await chooserPromise;
  await chooser.setFiles(path.resolve(__dirname, 'fixtures/cover.jpg'));

  await expect(page.getByRole('dialog', { name: 'Review import' }))
    .toBeVisible();
  await expect(page.getByText('cover.jpg', { exact: true })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Import 1 file' }))
    .toBeEnabled();
});

This path costs more coupling. The test now depends on the button opening a chooser and the chooser accepting the file. That is appropriate when the button creates the input or performs required setup. It is unnecessary when a stable, labelled input already represents the same contract. Do not use force: true on a covered upload button to make the test move. A user unable to click the control is a real defect.

In-memory payloads solve a different problem. They are excellent for exact boundary data without checking large fixtures into the repository. The following example assumes the product limit is five megabytes and rejects the file before upload. The buffer size is test input derived from that stated rule, not an observed performance figure.

TypeScript
// tests/uploads/size-validation.spec.ts
import { test, expect } from '@playwright/test';

test('rejects a video above the configured client limit', async ({ page }) => {
  await page.goto('/media/new');

  await page.getByLabel('Choose video').setInputFiles({
    name: 'too-large.webm',
    mimeType: 'video/webm',
    buffer: Buffer.alloc(5_000_001),
  });

  await expect(page.getByRole('alert')).toHaveText(
    'Video must be 5 MB or smaller',
  );
  await expect(page.getByRole('button', { name: 'Upload video' }))
    .toBeDisabled();
});

The disabled submit button makes the rejection consequential. An alert with an enabled upload path could still let invalid data through. Add a request assertion only if the page might upload immediately on selection; in that design, the important negative result is that no upload request is accepted by the application. Be cautious with "no request" waits because they add fixed latency. A server-side rejection response and visible error can be a stronger, faster contract when immediate upload is intentional.

Multiple-file controls introduce ordering and partial-removal bugs that a single-file case cannot expose. The browser's selected list has an order, the application may build a separate queue, and the server may return completed items in yet another order. Decide what the user contract says before asserting. A gallery that promises manual ordering should preserve the order after the user moves an item. A bulk importer that explicitly treats files as an unordered set should not acquire a brittle ordering assertion merely because the current implementation happens to finish sequentially.

For a worked gallery case, select three visibly different fixtures in one setInputFiles() call. Assert three review cards with their correct names, remove the middle card through its accessible Remove button, and confirm that exactly the first and third remain. Then move the third card before the first using the product's reorder control and submit. The final gallery, or a server-backed review screen, must show the new two-item order. That sequence catches an index bug where removing item one deletes item two, a stale hidden input that submits all three files, and a reorder operation that changes only the DOM.

Do not prove the last point by comparing the test's original array with itself. Read the result from a different boundary. If submission navigates to a saved gallery, assert the rendered saved order there. If the application exposes a documented API response, inspect the returned media identifiers and then confirm the UI maps them correctly. A queue object captured before the product handles it is diagnostic input, not a result.

Clearing a selection deserves one narrow case when Cancel is part of the product. Playwright accepts an empty array to clear a file input, but calling that method directly bypasses the application's Cancel button. Use the direct empty-array operation only to test low-level input behavior or to prepare state. To protect the user workflow, select a file, click Cancel, and assert that the preview disappears, the input has no files, and Submit returns to its initial disabled state. Each assertion covers a separate cleanup responsibility. The cost is more coupling to UI state, but cancellation bugs often leak object URLs, stale filenames, or unintended uploads.

Prove image and video workflows at useful boundaries

A decoded preview and a saved server result deserve separate assertions because they catch different code. Consider an image editor that previews the original file locally, sends crop coordinates to a service, and downloads a square PNG. The local preview can pass while the export endpoint ignores the crop. Checking only the screen misses the broken deliverable.

Wait for the download before clicking the export control. Playwright emits a download event for page-initiated attachments and provides a Download object. The object exposes the suggested filename and can save the payload to a test-owned path. Downloads live in temporary storage and are deleted when their browser context closes, so save the file before teardown when later assertions or CI artifacts need it.

The following parser reads only the stable fields needed from a PNG: its eight-byte signature and the width and height stored in the IHDR chunk. It does not pretend to validate every PNG rule. The product contract in this example promises a 256 by 256 export, so those dimensions are the meaningful oracle.

TypeScript
// tests/uploads/crop-export.spec.ts
import { readFile } from 'node:fs/promises';
import { test, expect } from '@playwright/test';

function readPngDimensions(bytes: Buffer) {
  const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
  expect(bytes.subarray(0, 8)).toEqual(signature);
  return {
    width: bytes.readUInt32BE(16),
    height: bytes.readUInt32BE(20),
  };
}

test('exports the selected square crop', async ({ page }, testInfo) => {
  await page.goto('/profile/avatar-editor');
  await page.getByLabel('Crop shape').selectOption('square');

  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download cropped image' }).click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toBe('avatar-square.png');
  expect(await download.failure()).toBeNull();

  const savedPath = testInfo.outputPath('avatar-square.png');
  await download.saveAs(savedPath);
  const bytes = await readFile(savedPath);
  expect(readPngDimensions(bytes)).toEqual({ width: 256, height: 256 });
});

A non-empty download would be a weak substitute. An HTML error page, original image, or placeholder could all contain bytes. The signature separates PNG from those obvious failures, and the dimensions prove the crop shape promised by this workflow. If color content matters, use an image decoder or visual comparison at a suitable layer. Do not write a home-grown decoder and imply it validates the format.

Video preview behavior brings another boundary. Start with a small checked-in clip encoded in a format supported by the browser project you run. After selection, wait for the product's preview-ready signal. Then click its Play control so playback follows a user gesture. A test that calls video.play() through evaluate() bypasses the control and can hide a broken event handler.

TypeScript
// tests/uploads/video-preview.spec.ts
import path from 'node:path';
import { test, expect } from '@playwright/test';

test('plays the selected video from the review screen', async ({ page }) => {
  await page.goto('/media/new');
  await page.getByLabel('Choose video').setInputFiles(
    path.resolve(__dirname, 'fixtures/preview.webm'),
  );

  const video = page.getByTestId('video-preview');
  await expect(video).toBeVisible();
  await expect.poll(() => video.evaluate((element: HTMLVideoElement) => ({
    readyState: element.readyState,
    hasSource: element.currentSrc.length > 0,
  }))).toEqual({ readyState: 4, hasSource: true });

  await page.getByRole('button', { name: 'Play preview' }).click();
  await expect(page.getByRole('button', { name: 'Pause preview' }))
    .toBeVisible();
  await expect.poll(() => video.evaluate(
    (element: HTMLVideoElement) => element.currentTime,
  )).toBeGreaterThan(0);
});

Requiring readiness state 4 means this example expects enough data for continuous playback. Some products deliberately begin at an earlier readiness threshold, especially when streaming. Match the assertion to that product behavior instead of copying the number blindly. The important pattern is to expose the browser's actual media state and then prove the user control advances playback.

If the same clip runs in Chromium but not WebKit, do not immediately label one browser flaky. Check whether the checked-in encoding is supported in both environments and whether your shipped product promises both. A test fixture is part of the test design. Choosing a browser-specific codec and then demanding a cross-browser result creates an infrastructure failure that no product change can fix.

Diagnose uploads, previews, and downloads separately

Begin with the last boundary known to work. If setInputFiles() throws, inspect the locator, input type, fixture path, and whether the element was replaced during rendering. If it completes but the metadata is wrong, inspect the selected FileList and the path or payload passed by the test. If metadata is correct but no preview appears, move to application logs and media state. If the preview works but submission fails, inspect the request and server response. If the server succeeds but export is wrong, parse the downloaded artifact.

For a media stall, attach browser state rather than a screenshot alone. A spinner looks the same when no source was assigned, the browser is still loading, decode failed, or playback is paused. currentSrc, readyState, networkState, paused, ended, and the media error code separate those shapes. They do not identify the root cause by themselves, but they prevent a timeout from erasing the useful distinction.

TypeScript
// Add this after a video element becomes visible.
const mediaState = await page.getByTestId('video-preview').evaluate(
  (element: HTMLVideoElement) => ({
    currentSrc: element.currentSrc,
    readyState: element.readyState,
    networkState: element.networkState,
    paused: element.paused,
    ended: element.ended,
    errorCode: element.error?.code ?? null,
  }),
);

await test.info().attach('media-state.json', {
  body: Buffer.from(JSON.stringify(mediaState, null, 2)),
  contentType: 'application/json',
});

Interpret the attachment with the trace. An empty currentSrc after correct selection points toward application wiring, such as a missing object URL or removed source element. A non-empty source with a media error points toward loading or decoding. A ready element that stays paused after a user click points toward the product control or a rejected play request. Preserve console errors because applications often log the rejected promise. Do not invent a universal message; browsers and application wrappers format those failures differently.

A missing preview can be a near-miss caused by the fixture itself. Confirm that the repository contains the expected bytes, not a large-file-storage pointer file or an empty artifact produced by another job. The file-input metadata shows the selected size, while a simple Node-side fixture check can verify a known hash when exact identity matters. Avoid logging entire media buffers. A hash and relative fixture name provide enough evidence without bloating reports.

Download diagnostics need similar discipline. If no download event arrives, determine whether the click navigated, opened a new tab, returned an inline response, or did nothing. Not every file response uses a download. The product may intentionally render a PDF or image in the page. Assert the promised user behavior rather than forcing every response through waitForEvent('download').

If the event arrives and download.failure() returns a message, the transfer failed before content validation. If saving succeeds but parsing fails, retain the suggested filename, saved size, and a small safe format signature. Check the Network panel in the trace for the export request, status, and content type, but remember that correct headers do not guarantee correct bytes. Conversely, a correct PNG with a generic content type may expose a response-header defect even when the image parser succeeds. Those are two assertions with different owners.

Submission failures require the same split between browser work and server work. Register a response wait before clicking Upload, filter it to the product's upload endpoint, and keep the returned status with the UI result. A preview that works alongside a 413 response means local selection and decode succeeded while a server, gateway, or application size limit rejected the request. A 201 response followed by an endless spinner points back to client state handling or to a later processing job. No matching request at all means the workflow stopped before network submission.

TypeScript
// tests/uploads/submission.spec.ts
import path from 'node:path';
import { test, expect } from '@playwright/test';

test('shows the media record returned after upload', async ({ page }) => {
  await page.goto('/media/new');
  await page.getByLabel('Choose image').setInputFiles(
    path.resolve(__dirname, 'fixtures/banner.png'),
  );

  const responsePromise = page.waitForResponse(response =>
    response.request().method() === 'POST'
    && new URL(response.url()).pathname === '/api/media',
  );
  await page.getByRole('button', { name: 'Upload image' }).click();
  const response = await responsePromise;

  expect(response.status()).toBe(201);
  const record: unknown = await response.json();
  expect(record).toEqual(expect.objectContaining({
    id: expect.any(String),
    name: expect.any(String),
  }));
  const { id, name } = record as { id: string; name: string };
  expect(id).not.toBe('');
  expect(name).toBe('banner.png');
  await expect(page.getByTestId(`media-${id}`)).toContainText(
    'banner.png',
  );
});

That example has three independent failure points: the HTTP status, the runtime response contract, and the record users see. The asymmetric matchers reject a missing field or a non-string id or name before the TypeScript assertion is used. The nonempty ID assertion then checks a stricter value rule, and the final locator uses that returned identity to prove the UI incorporated the saved record. In a system where the response contains sensitive metadata, parse only the fields required for the assertion and do not attach the full body by default.

The endpoint filter should be specific enough to ignore analytics and thumbnail requests without hard-coding an entire environment-specific origin. Match the method and URL pathname when the path is stable. If several files upload concurrently to the same path, correlate with a product request ID, file name field, or the response used by the clicked item. Simply taking the first matching response can attach one card's result to another and create a race that appears only under parallel processing.

Object URL cleanup can mimic a codec failure. Applications often use URL.createObjectURL(file) for a local preview and later call URL.revokeObjectURL(). Revoking before the media element has loaded can leave a blob URL in currentSrc while decoding fails. Revoking too late can leak memory during a long editing session. The browser case should focus on the user result, such as a preview that becomes ready and disappears after Cancel. Memory-leak measurement belongs in a focused performance or component investigation, not an invented number in this functional article.

The hardest near-miss is a backend job that is still processing. A fixed sleep before clicking Download may pass locally and fail under CI load. Prefer a visible job status, a polling endpoint the UI uses, or an enabled Download control that appears only when processing finishes. Let Playwright's web-first assertion wait for that state. The trade-off is test duration: real transcoding can make an end-to-end case slow and expensive. Keep one representative processing case and move the combinatorial format matrix to service tests.

Wire artifacts into CI without path collisions

Use testInfo.outputPath() for files owned by one test attempt. Playwright creates a unique output directory for each test, which prevents parallel workers from saving every download as output/avatar.png. A repository-level downloads/ directory creates collisions, stale passes, and cleanup arguments. The crop example saves into the test's output directory for this reason.

Configure traces and screenshots so a failed transition retains evidence. The setup below runs ordinary upload behavior across the three browser projects while allowing a playback test to skip projects whose codec contract is not supported. Do not skip merely because a result is inconvenient; record the product's supported browser and format matrix, then encode that decision in the test annotation.

TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/uploads',
  outputDir: './test-results/uploads',
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: devices['Desktop Chrome'] },
    { name: 'firefox', use: devices['Desktop Firefox'] },
    { name: 'webkit', use: devices['Desktop Safari'] },
  ],
  webServer: {
    command: 'npm run preview',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
  },
});

Checked-in fixtures should be small, licensed for the repository, and described by expected properties. Record dimensions, format, and duration in a fixture README or helper, not as invented benchmark results. If a media file is intentionally malformed, name the defect it carries. Future maintainers must know whether replacing it is a cleanup or a test break.

Keep fixture identity stable across developer machines. Text-based large-file pointers, download scripts, or generated assets can leave CI with different bytes under the same filename. A preflight script may verify that required files exist and match committed hashes, but it should fail with the relative fixture name and expected acquisition step. It should not silently download a newer sample, because that changes test input without review. If repository size makes real video fixtures unacceptable, publish a versioned test-artifact package and pin its digest in CI.

Browser caches can also hide a missing asset when tests navigate to server-hosted fixtures rather than uploading local paths. A warm developer profile may retain the old media while an isolated CI context receives a 404. Prefer local file selection for upload inputs. When the product intentionally fetches remote media, assert that network response as part of the product workflow and keep cache state isolated.

Roll an existing suite over by strengthening one boundary at a time. First, replace relative fixture strings with resolved paths and assert selected metadata. Second, add decoded-preview evidence to happy paths. Third, add one rejection case using an in-memory payload. Fourth, save and inspect a representative download. Watch the first failures before converting the rest. A wave of new codec failures may reveal unsupported CI images rather than dozens of application regressions.

Artifact retention has a privacy cost. User uploads can contain faces, documents, or customer data. Automated tests should use synthetic repository fixtures, not copied production media. Retain failed downloads only as long as the debugging policy requires. Screenshots and traces may capture previews, so the test-data decision must cover those artifacts too.

Know when browser automation is the wrong layer

Do not run a real transcoder for every extension, resolution, and error combination through the browser. The suite becomes slow, consumes substantial CPU, and produces long queues on ordinary pull requests. Test transformation rules close to the service, then keep a few browser journeys that prove wiring from selection to result.

Do not use a generated in-memory buffer as proof that a browser can decode a real media format. Buffer.alloc() produces bytes, not a valid WebM file. It is appropriate for a size-limit rejection that happens before decode. Playback and image-decoding cases need valid, known fixtures.

Do not assert only a blob URL. A blob: string proves that code created or assigned a URL, not that its bytes decode. Pair it with intrinsic image dimensions, media readiness, or another observable result. Similarly, do not assert only that the Upload button was clicked. Method completion is not user success.

Do not turn file type strings into a security claim. Client-side accept attributes and MIME checks improve feedback, but a caller can submit different bytes outside the UI. Keep server enforcement and hostile-file tests in scope even when the browser suite is green.

Do not force clicks through overlays or disabled controls to make a workflow pass. File processing often disables Submit for a reason. A forced action bypasses the exact readiness rule the user encounters. Wait for a justified state, or fix the product if that state never arrives.

Do not compare a full video frame sequence in every functional test. Rendering can vary across codecs, operating systems, and hardware paths. Use stable functional signals for play, pause, progress, captions, and errors. Add focused visual baselines only for frames and controls whose rendering is a supported contract, and accept the maintenance cost of platform-specific snapshots.

The browser journey earns its runtime when it connects boundaries cheaper tests cannot: a real file input, a decoded browser preview, the application's request, and a user-received artifact. Keep those assertions independent enough that the first failure says which boundary broke. That diagnostic precision is what turns a media test from a slow demo into an engineering tool.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Should I click the upload button or call setInputFiles in Playwright?

Use setInputFiles on a stable file input when the contract is file selection and processing. Wait for the filechooser event before clicking when the button dynamically creates the input or the chooser path itself is important.

How can I prove an uploaded image actually loaded in the preview?

Check a user-visible preview state and inspect the image element's complete and naturalWidth properties. A filename beside a broken image only proves that the application read file metadata.

Why does a video preview pass locally and stall in CI?

Codec support, a missing fixture, autoplay policy, or an application race can produce similar symptoms. Attach the media element's currentSrc, readyState, networkState, paused state, and error code before changing timeouts.

Does Playwright validate the contents of a downloaded file?

The Download object exposes the transfer and lets the test save it, but content validation belongs to your assertion code. Parse the format or inspect stable fields instead of treating a non-empty file as a complete product oracle.

Can browser upload tests replace server-side file security tests?

No. Browser tests cover selection, client validation, submission, and feedback, while the server must independently enforce size, type, authorization, scanning, and storage rules.