Testing and Debugging Web Share Flows
This guide is part of Web Share API and Security Contexts.
A share button is unusually hard to test. The success path ends in operating-system UI that no automation framework can reach, the API is missing entirely on most desktop browsers, and the most common outcome — the user dismissing the sheet — arrives as a rejected promise that looks exactly like a failure. Teams respond by not testing it at all, then discover in production that the fallback never fires on desktop, or that a dismissal is being reported as an error to users.
The way out is to stop treating “sharing” as one indivisible action. There is a decision (is native sharing available and valid for this payload?), an invocation (calling navigator.share inside a user gesture), and an outcome (shared, dismissed, or failed). The decision is pure logic and can be unit tested exhaustively. The invocation can be verified in a real browser with a stub. Only the sheet itself needs hardware — and that is a short manual checklist, not a regression suite.
What Breaks Without a Test Strategy
The failures that reach production are remarkably consistent, and all of them are cheap to catch.
The first is a fallback that never runs. Because the developer’s phone supports navigator.share, the native branch is the only one anyone exercises. On desktop Chrome the API is missing, the code throws a TypeError on navigator.share(...), and the click handler dies silently — no share, no clipboard copy, no message. The copy-to-clipboard fallback exists in the codebase but nothing routes to it.
The second is treating dismissal as failure. Users tap Share, change their mind, and swipe the sheet away. The promise rejects with AbortError, an unconditional catch block shows “Sharing failed — please try again”, and the app has invented an error the user never experienced. Handling AbortError when the share sheet is dismissed covers the distinction in depth.
The third is a payload that only validates on one platform. Sharing files works on Android Chrome and throws on a desktop build that supports text-only sharing, because nothing called navigator.canShare(payload) first. A single unit test over the decision function catches it permanently.
Prerequisites
- A secure context for any manual run:
http://localhostcounts, but a LAN IP does not — see configuring HTTPS for local development. - A share entry point that is a pure function of the environment plus the payload, so it can be called without a DOM.
- A test runner that lets you replace globals per test (Vitest, Jest, or
node:testwith a DOM shim). - For browser-level tests, Playwright or Puppeteer with the ability to run an init script before page scripts.
- A physical Android device and an iPhone, or access to a device cloud, for the manual pass.
Browser Support Snapshot for Test Environments
Test environments differ from user environments in exactly the way that matters: most of them have no share implementation at all.
| Environment | navigator.share |
Notes |
|---|---|---|
| Chrome on Android | Present | Full support including files; the primary manual target |
| Safari on iOS/iPadOS | Present | Files supported; sheet is modal and blocks the page |
| Safari on macOS | Present | Desktop share sheet; file support is narrower than iOS |
| Chrome on Windows / ChromeOS | Present | Routes to the OS share UI |
| Chrome on Linux | Usually absent | No OS share target, so the unsupported branch runs |
| Headless Chrome / Chromium | Absent | Treat as the unsupported branch or install a stub |
| Firefox desktop | Absent | Exercises the fallback path |
| jsdom / happy-dom | Absent | navigator.share must be defined by the test itself |
The practical consequence: your CI runs the fallback path by default. That is useful — it means the unsupported branch is continuously regression tested — but it also means the native branch is never covered unless you deliberately stub it in.
Step 1 — Extract a Pure Routing Decision
Everything testable starts with separating “what should happen” from “make it happen”. The decision function takes the environment and payload and returns a route; it touches no UI and opens nothing.
// share-route.js
export function decideShareRoute(payload, env = globalThis) {
if (env.isSecureContext !== true) {
return { route: 'fallback', reason: 'insecure-context' };
}
if (typeof env.navigator?.share !== 'function') {
return { route: 'fallback', reason: 'unsupported' };
}
if (payload.files?.length && typeof env.navigator.canShare !== 'function') {
return { route: 'fallback', reason: 'no-canshare' };
}
if (typeof env.navigator.canShare === 'function' && !env.navigator.canShare(payload)) {
return { route: 'fallback', reason: 'invalid-payload' };
}
return { route: 'native' };
}
Because env is injected, a test can describe any browser in three lines — no globals to mutate and restore, and no ordering hazards between tests. Note the check order: secure context first, then existence, then canShare. That ordering is the site-wide rule described in understanding secure context requirements, and testing it explicitly stops a well-meaning refactor from reversing it.
// share-route.test.js
import { describe, it, expect } from 'vitest';
import { decideShareRoute } from './share-route.js';
const env = (over = {}) => ({
isSecureContext: true,
navigator: { share: () => Promise.resolve(), canShare: () => true, ...over.navigator },
...over
});
describe('decideShareRoute', () => {
it('routes to fallback on an insecure origin', () => {
expect(decideShareRoute({ url: '/a' }, env({ isSecureContext: false })).reason)
.toBe('insecure-context');
});
it('routes to fallback when the API is absent', () => {
expect(decideShareRoute({ url: '/a' }, env({ navigator: { share: undefined } })).reason)
.toBe('unsupported');
});
it('routes to fallback when canShare rejects the payload', () => {
expect(decideShareRoute({ files: [new File([], 'a.txt')] },
env({ navigator: { canShare: () => false } })).reason).toBe('invalid-payload');
});
it('routes to native when everything validates', () => {
expect(decideShareRoute({ url: '/a' }, env()).route).toBe('native');
});
});
Step 2 — Stub the API for the Invocation Path
With routing covered, the remaining question is whether the invocation behaves correctly for each way the promise can settle. A single configurable stub covers all three cases, and returning the recorded calls keeps assertions readable.
// share-stub.js
export function installShareStub(target, behaviour = 'resolve') {
const calls = [];
target.navigator.share = async (payload) => {
calls.push(payload);
if (behaviour === 'abort') {
throw new DOMException('Share canceled', 'AbortError');
}
if (behaviour === 'denied') {
throw new DOMException('Permission denied', 'NotAllowedError');
}
return undefined;
};
target.navigator.canShare = () => true;
return calls;
}
Constructing a real DOMException matters. A plain new Error('AbortError') has name === 'Error', so a handler that branches on err.name passes the test and fails in the browser — a false green that survives right up to the first user who dismisses the sheet.
// share-action.test.js
import { it, expect } from 'vitest';
import { installShareStub } from './share-stub.js';
import { shareContent } from './share-action.js';
it('reports dismissal separately from failure', async () => {
installShareStub(globalThis, 'abort');
const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });
expect(result.outcome).toBe('dismissed');
expect(result.showedError).toBe(false);
});
it('falls back when permission is denied', async () => {
installShareStub(globalThis, 'denied');
const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });
expect(result.outcome).toBe('fallback');
});
The NotAllowedError case deserves its own test because it is the one users cannot recover from without a fallback — the cause and cure are covered in fixing NotAllowedError on navigator.share.
Step 3 — Drive the Flow in a Real Browser
Unit tests cannot prove that the click handler is wired up, that the gesture reaches share() synchronously, or that the button is reachable at all. A browser test can, provided the stub is installed before page scripts run.
// share.spec.js — Playwright
import { test, expect } from '@playwright/test';
test('share button hands the canonical URL to the native API', async ({ page }) => {
await page.addInitScript(() => {
window.__shareCalls = [];
navigator.share = async (payload) => { window.__shareCalls.push(payload); };
navigator.canShare = () => true;
});
await page.goto('/articles/web-share-api/');
await page.getByRole('button', { name: /share/i }).click();
const calls = await page.evaluate(() => window.__shareCalls);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://www.example.com/articles/web-share-api/');
});
addInitScript runs in every new document before any page script, which is the only reliable window for replacing navigator.share. Installing the stub after goto is a common mistake: the app may have already captured a reference to the original during module evaluation.
The complementary test is the one with no stub at all. Headless Chromium has no share implementation, so the fallback must engage:
test('falls back to clipboard when the API is absent', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/articles/web-share-api/');
await page.getByRole('button', { name: /share/i }).click();
await expect(page.getByRole('status')).toHaveText(/link copied/i);
expect(await page.evaluate(() => navigator.clipboard.readText()))
.toBe('https://www.example.com/articles/web-share-api/');
});
Step 4 — Verify on Physical Hardware
Automation stops at the sheet. Everything past that boundary is a short manual pass, run once per release rather than per commit.
For Android, connect the device over USB with developer mode on, open chrome://inspect#devices on the desktop, and inspect the tab. You get a full DevTools session — console, network, and breakpoints — against the real browser, so you can confirm the sheet opens, watch the promise settle after you pick a target, and see the AbortError when you swipe it away.
For iOS, enable Web Inspector under Settings → Safari → Advanced, connect the phone, and open Safari on macOS → Develop → device → the page. The same caveat applies as everywhere on iOS: the share sheet is modal, and JavaScript timers in the page are throttled while it is open, so a spinner started before the call may appear frozen.
The manual checklist worth keeping short:
- The sheet opens on the first tap, not the second — a sign the gesture is being lost to an
awaitbefore the call. - Dismissing the sheet leaves no error message on screen.
- Picking a target completes, and the app’s confirmation appears afterwards.
- With airplane mode on, a queued share is captured rather than lost — see offline share queue implementation.
- On desktop Safari, a file payload either shares or cleanly routes to the fallback.
Step 5 — Instrument Outcomes Without Capturing Content
Once the flow works, you want to know how it behaves for real users — and this is where a share integration quietly becomes a privacy problem. The payload is the user’s content: a private document URL, a note, a photo file name. None of it belongs in analytics.
Log the shape of the event, never its values.
// share-telemetry.js
export function shareEvent(outcome, payload, error) {
return {
outcome, // 'shared' | 'dismissed' | 'fallback' | 'failed'
errorName: error?.name ?? null, // 'AbortError', 'NotAllowedError', 'DataError'
hasTitle: Boolean(payload?.title),
hasText: Boolean(payload?.text),
hasUrl: Boolean(payload?.url),
fileCount: payload?.files?.length ?? 0,
fileBytesBucket: sizeBucket(payload?.files) // 'none' | '<1MB' | '1-10MB' | '>10MB'
};
}
function sizeBucket(files) {
if (!files?.length) return 'none';
const total = files.reduce((sum, f) => sum + f.size, 0);
if (total < 1_000_000) return '<1MB';
if (total < 10_000_000) return '1-10MB';
return '>10MB';
}
This shape answers every operational question that matters — what fraction of users get the native path, how often payload validation rejects a file, which platform produces the most NotAllowedError — without a single byte of user content leaving the device. It also keeps the event usable under strict consent regimes, where transmitting shared content would require a legal basis that a UI metric does not.
Platform Gotchas That Only Appear Under Test
The gesture budget is consumed by await. Any await between the click and navigator.share() risks losing the user-activation state, and Safari is the strictest. Tests that stub share() will pass regardless, because a stub does not check activation. Guard against it structurally: build the payload before the handler, and make the handler’s first await the share call itself.
canShare is not universally present. Older Safari versions expose navigator.share without navigator.canShare. Code that calls canShare unconditionally throws a TypeError — on a browser that fully supports sharing. The decision function above checks typeof for exactly this reason.
File payloads fail late. A DataError surfaces only when the sheet tries to accept the files, so a payload that passes canShare can still reject on a specific target. Resolving DataError when sharing files covers the recovery path; in tests, add a stub behaviour that rejects with new DOMException('...', 'DataError').
Headless runs never see the native branch. Add an assertion to CI that at least one test installs a stub, so a refactor cannot silently orphan the native path.
Verifying Your Test Suite Actually Covers the Flow
A quick audit that catches most gaps: for each of the five outcomes — native success, dismissal, permission denial, invalid payload, and API absent — point to the test that covers it. If any outcome has no test, that is the branch users will find first. Run the suite once with navigator.share forcibly deleted and once with it stubbed; both runs should be green, and both should exercise different code.
FAQ
Can I automate the native share sheet itself?
No. The share sheet is operating-system UI rendered outside the browser’s process, so neither Playwright, Puppeteer, Selenium nor WebDriver can see or click it. Automated tests should assert that navigator.share was called with the correct payload and that each resolution path is handled; confirming the sheet itself renders is a manual device check.
Why does navigator.share work on my phone but not in headless Chrome?
Desktop and headless Chrome builds frequently ship without a share implementation because there is no OS share target to hand off to, so navigator.share is undefined. That is the correct unsupported branch of your code, not a bug — test it by asserting your fallback runs, and stub navigator.share when you want to exercise the native branch.
How do I test the AbortError path when a user dismisses the sheet?
Replace navigator.share with a stub that rejects with a DOMException whose name is AbortError. Construct it as new DOMException('Share canceled', 'AbortError') so the name property matches what browsers throw, then assert your handler treats it as a silent, non-error cancellation.
Is it safe to log what users share?
No. Share payloads routinely contain private URLs, personal notes, and file names, and sending them to analytics turns a UI event into a data-collection event. Log only the outcome, the error name, the payload shape, and coarse size buckets — never the field values themselves.
Do I need HTTPS to test the Web Share API locally?
You need a secure context, and http://localhost already qualifies as one, so plain local development works. The moment you test from a phone against your machine’s LAN IP the origin is no longer trusted and navigator.share disappears, which is why device testing needs a real certificate or a tunnel.