July 18, 2026
How to Test the File System Access API in Browsers Without Losing Your Sanity
A practical tutorial for frontend engineers and QA teams on how to test the File System Access API in browser, including permission prompts, file picker flows, directory upload testing, and common failure cases.
The File System Access API looks simple when you read the happy-path examples, then it immediately becomes annoying the moment you try to test it in a real browser. That is not a flaw in the API itself, it is a side effect of what the API is doing: bridging JavaScript, browser chrome, user permissions, operating-system file pickers, and security constraints that exist for good reasons.
If your team needs to test file system access API in browser flows, the important question is not just whether a test can click a button. The question is whether your tests can reliably cover permission prompts, picker cancellation, directory traversal, read and write operations, and the failure modes that users actually hit when they move between Chrome, Edge, and Safari-like environments.
This article is a reproducible, practical guide to that problem. It assumes you are building a web app that uses showOpenFilePicker, showSaveFilePicker, showDirectoryPicker, FileSystemFileHandle, or FileSystemDirectoryHandle, and you want a testing strategy that works in local development, automated browser runs, and CI.
What the File System Access API actually gives you
The File System Access API lets a web app interact with local files and directories through browser-mediated user gestures. In practice, that means:
- opening a file picker and reading user-selected files,
- saving content back to a chosen file,
- selecting a directory and iterating through its contents,
- writing to files only after permission is granted,
- handling browser-specific security and permission behavior.
The API is valuable because it can replace clumsy upload-only workflows with something closer to desktop app behavior. But it also means your test surface is broader than a normal form submission. You are not just testing DOM state, you are testing a browser capability that depends on user activation and permission state.
A good File System Access test does not just prove that one button works. It proves that your app behaves correctly when the browser says yes, says no, or changes its mind.
For background on the broader practice of validating software behavior, see software testing, and for why automation matters here, test automation is a useful framing. When these flows run in CI, they also intersect with continuous integration, because browser availability and environment isolation matter.
Why this API is hard to automate
Testing gets messy for a few predictable reasons.
1. Browser dialogs are not normal DOM
The file picker is a native browser UI, not HTML in your page. Your test runner cannot inspect it the way it inspects a button or a div. Automation frameworks can usually trigger the picker, but the picker interaction often requires special handling, browser permissions, or direct file chooser APIs.
2. User activation is required
Most File System Access calls must happen during a user gesture, such as a click. If your code calls showOpenFilePicker() from a timeout, promise chain, or background effect, browsers may reject it. That failure mode is easy to miss in unit tests and only appears in integration runs.
3. Permissions are stateful
A browser can remember that a page has read or read-write access to a file or directory handle. Tests that assume a pristine permission state will become flaky unless they isolate profiles or explicitly model permission setup.
4. Browser support is uneven
The API is strongest in Chromium-based browsers. If your product must support Firefox or Safari, you may need fallback paths such as <input type="file"> plus download links or server-side uploads. Your testing plan has to cover capability detection and fallback behavior, not just the primary path.
5. Directory operations create edge cases
Directory upload testing is useful, but it introduces recursion, empty folders, nested structures, symbolic link oddities on some systems, and name collision behavior when saving files back to disk.
A test strategy that actually scales
You do not need one giant end-to-end test for every browser feature. You need layered coverage.
Layer 1: unit tests for pure logic
Anything that transforms file content, validates filenames, computes diffs, or decides what to save should be unit tested without the browser API involved. Mock the file content, not the picker.
Examples of logic that belong here:
- parsing text or JSON from selected files,
- validating extensions or MIME types,
- deciding whether a save button should be enabled,
- serializing app state to a download payload.
Layer 2: integration tests for API boundaries
Test the code that wraps window.showOpenFilePicker(), showSaveFilePicker(), and directory traversal. These tests should verify that your application handles success and failure paths correctly.
Layer 3: browser automation for real picker behavior
Use a browser automation framework to prove that the app can trigger file selection and consume a real file handle. This is where you validate the actual browser interaction model, especially around permissions and user activation.
Layer 4: cross-browser smoke coverage
At minimum, run capability checks and a few critical flows in the browsers your users actually use. If a browser does not support the API, your test should verify the fallback path rather than failing for the wrong reason.
Start with feature detection, not assumptions
Do not hard-code a browser version assumption unless you have to. Prefer capability detection in app code and test that detection directly.
export function supportsFileSystemAccess() {
return typeof window !== 'undefined' && 'showOpenFilePicker' in window;
}
A test around this check is simple, but useful. It lets you verify the app chooses the correct path before you start simulating picker interactions.
import { test, expect } from '@playwright/test';
test('shows fallback when File System Access API is unavailable', async ({ page }) => {
await page.addInitScript(() => {
// Simulate a non-supporting browser context
delete (window as any).showOpenFilePicker;
});
await page.goto(‘/editor’); await expect(page.getByText(‘Upload a file’)).toBeVisible(); });
This is not a replacement for real browser testing. It is the guardrail that keeps your product from crashing in unsupported environments.
Testing file picker flows with Playwright
For browser file picker testing, Playwright is usually the most straightforward tool because it has direct support for file chooser events and a clean model for browser contexts.
A common pattern is to wait for the chooser, trigger the action, then attach a file. That works well for <input type="file">, and it also helps when your UI uses the File System Access API behind a button that eventually opens a native picker.
import { test, expect } from '@playwright/test';
test('imports a text file through the picker flow', async ({ page }) => {
await page.goto('/import');
const fileChooserPromise = page.waitForEvent(‘filechooser’); await page.getByRole(‘button’, { name: ‘Open file’ }).click(); const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(‘fixtures/example.txt’); await expect(page.getByText(‘Imported 1 file’)).toBeVisible(); });
That snippet is great when your app uses a standard file input or a code path that ends in the browser file chooser. But when you want to test the actual File System Access API, there are two important caveats:
- The API requires a secure context, usually HTTPS or localhost.
- The browser may require a real user activation, so the click must happen as the direct trigger.
If your app calls showOpenFilePicker() directly, structure the test around the click and the resulting app state, not around DOM assumptions about the picker.
import { test, expect } from '@playwright/test';
test('opens a file with the File System Access API', async ({ page }) => {
await page.goto('/editor');
await page.getByRole(‘button’, { name: ‘Open file’ }).click(); await expect(page.getByText(‘No file selected’)).toBeHidden(); });
In practice, the assertion should be tied to what your app shows after the handle is resolved, such as the filename, editor contents, or a status banner.
Testing permission prompts and permission state
Permissions are where many teams lose time, because the first test passes and the second test fails for no obvious reason. The browser may remember a previous decision, or the test runner may reuse a context with a persisted profile.
A practical approach is to make permission state explicit in your tests.
Keep browser contexts isolated
Use a fresh browser context per test or per scenario. If you need persistent permissions, make that a deliberate choice rather than an accident.
Test the denied path
Your app should handle a denial gracefully, showing a useful message and preserving user state.
import { test, expect } from '@playwright/test';
test('handles denied file access cleanly', async ({ page }) => {
await page.goto('/editor');
await page.route(‘**/api/log’, route => route.fulfill({ status: 200, body: ‘{}’ })); await page.getByRole(‘button’, { name: ‘Open file’ }).click();
await expect(page.getByText(‘Permission denied’)).toBeVisible(); });
This example assumes your app surfaces denial in the UI. If the browser throws a NotAllowedError, your app should catch it and translate it into something a user can act on.
Test the re-prompt or retry path
A lot of apps fail here. They show an error, then keep a broken internal state that prevents the user from trying again. Test that a retry button or repeated action works after denial.
If a permission error leaves your UI in a dead end, users often interpret it as data loss, even if nothing was lost.
Directory upload testing without hand-waving
Directory upload testing is more than “can I select a folder.” You need to validate traversal, nested structure handling, file naming, and empty directories.
Common cases worth covering:
- a folder with a single file,
- nested folders with multiple levels,
- empty directories,
- files with identical names in different subdirectories,
- large directories where you only read a subset,
- cancellation before the directory is selected.
If your implementation uses showDirectoryPicker(), keep the traversal logic separate from the picker call. That makes it easier to test the walker function with mocked directory handles.
typescript
async function listFiles(dirHandle: FileSystemDirectoryHandle): Promise<string[]> {
const files: string[] = [];
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === 'file') files.push(name);
}
return files;
}
That function is simple on purpose. It is easier to test a small traversal helper than to debug a full picker interaction that mixes selection, validation, and rendering.
Handle save flows like they can fail, because they can
Saving is trickier than opening because write permission and file replacement behavior are involved. A user might approve the picker once, then later revoke access or switch files.
A save flow should be tested for:
- first-time write permission request,
- overwriting an existing file,
- saving to a new file name,
- cancellation after the save dialog opens,
- write failure due to permission loss.
A minimal save test might look like this:
import { test, expect } from '@playwright/test';
test('saves the current document', async ({ page }) => {
await page.goto('/editor');
await page.getByLabel('Document content').fill('hello world');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(‘Saved’)).toBeVisible(); });
The important part is not the click, it is what your app does after the save handle resolves. For example, if saving involves createWritable(), you should verify that the UI reflects successful completion only after the stream is closed.
Common failure modes that are worth testing explicitly
These are the bugs that tend to show up after release if nobody tests them.
1. Missing user gesture
If the picker call is delayed by async state updates, browsers may reject it. This often happens when developers wrap the call in a promise chain or a state effect.
2. Using the wrong origin
Local file access requires a secure context. If your dev server or preview environment is not served over localhost or HTTPS, the API may be unavailable.
3. Stale permission assumptions
An app can cache a handle and assume it remains writable. In reality, permission may need to be queried again, especially after reloads or across sessions.
4. Unsupported browser fallback gaps
Teams often implement the primary Chromium path and forget that other browsers still need a working upload fallback. Your test suite should verify both the supported path and the fallback path.
5. Error handling that hides the real problem
Catching everything and showing “Something went wrong” is not enough. Distinguish AbortError, NotAllowedError, and read or write failures where possible.
How to structure the app for testability
The easiest way to make these tests sane is to keep browser API calls at the edges.
A maintainable structure looks like this:
- UI layer: button clicks, status messages, and rendering,
- picker adapter: calls to
showOpenFilePickerorshowDirectoryPicker, - file operations layer: read, write, and traversal logic,
- domain logic: validation, parsing, transformation.
That separation means you can mock the adapter in fast tests and reserve real browser automation for a smaller number of high-value flows.
A useful rule of thumb:
If a test needs to understand how the browser picker works, you already waited too long to isolate the browser boundary.
CI considerations that save time later
Browser-based file system tests can be stable in CI, but only if you make the environment boring.
Keep the browser matrix small at first
Start with the browsers your product actually supports. Do not multiply scenarios until the primary flows are stable.
Run tests in a real browser channel when needed
Some APIs lag behind in automated headless modes or differ between stable and beta channels. If your app depends on a newer capability, verify it in the channel that matches production risk.
Use ephemeral state
Isolate profiles, use temporary directories, and avoid sharing browser context state across tests unless a scenario specifically requires persistence.
Save artifacts on failures
If a picker flow fails, the console logs and screenshots are often more useful than a stack trace alone. When the browser rejects a permission request, the UI state matters more than the click sequence.
Make unsupported capability a pass condition when appropriate
If your app intentionally falls back on unsupported browsers, your CI should not treat lack of showOpenFilePicker as an error. The test should assert the fallback behavior instead.
A practical checklist for teams
Use this as a pre-merge checklist for File System Access work:
- feature-detect the API before calling it,
- keep picker calls inside direct user gestures,
- separate picker adapters from file processing logic,
- test success, denial, cancellation, and retry paths,
- verify directory upload traversal on nested structures,
- isolate browser contexts to avoid permission leakage,
- cover fallback flows for unsupported browsers,
- confirm save behavior after write permission is granted,
- log enough detail to distinguish AbortError from NotAllowedError,
- run at least one end-to-end flow in a real browser.
A realistic testing model beats a heroic one
The temptation with browser file access is to build one giant test that proves everything. That usually produces a brittle script that knows too much about timing, browser dialogs, and app internals.
A better model is smaller and more honest:
- unit tests for parsing and validation,
- integration tests for the API wrapper,
- browser automation for one or two critical user journeys,
- cross-browser checks for support and fallback behavior.
That approach is less glamorous, but it is much easier to maintain. It also gives you a better signal when a test fails, because the failure points to a specific layer rather than “the whole thing broke.”
Final thought
Testing the File System Access API is not really about file pickers. It is about respecting the fact that the browser, the operating system, and the user are all part of the contract.
If you test only the happy path, you will miss the parts that hurt real users, permission denial, canceled dialogs, unsupported browsers, stale handles, and write failures. If you test too much through one giant browser script, you will spend your life debugging the test instead of the product.
The sweet spot is a layered suite with explicit permission cases, realistic browser automation, and small isolated helpers. That is the difference between “we added file access” and “we can safely ship file access.”