Writing Playwright Tests for Share Flows
This guide is part of Testing and Debugging Web Share Flows, which itself is part of Web Share API and Security Contexts.
Playwright can cover almost everything about a share button except the share sheet: that the button exists and is reachable, that clicking it calls navigator.share exactly once, that the payload contains the canonical URL rather than a tracking-decorated one, that a dismissal produces no error toast, and that a browser without the API takes the fallback path instead of throwing. That is the majority of what actually breaks, and none of it requires a phone.
The single detail that decides whether these tests are meaningful is when the stub is installed. Replace navigator.share too late and you are testing a page that has already made its support decision.
Feature Detection Gate
Test the same gate the application uses, so a test failure points at a real behaviour rather than at test scaffolding.
// share-support.js — shared by the app and the specs
export function nativeShareAvailable(payload, env = globalThis) {
if (env.isSecureContext !== true) return false;
if (typeof env.navigator?.share !== 'function') return false;
if (typeof env.navigator.canShare === 'function') return env.navigator.canShare(payload);
return !payload?.files?.length;
}
Playwright serves pages over http://localhost, which is already a secure context, so isSecureContext is true in tests without extra configuration — the same rule described in understanding secure context requirements.
Step 1 — Install the Stub Before Page Scripts Run
addInitScript is evaluated in every new document before any of the page’s own scripts. That is the only window in which replacing navigator.share is guaranteed to be observed by application code.
// fixtures/share.js
export async function stubShare(page, behaviour = 'resolve') {
await page.addInitScript((mode) => {
window.__shareCalls = [];
navigator.canShare = () => true;
navigator.share = async (payload) => {
window.__shareCalls.push(structuredClone({
title: payload.title,
text: payload.text,
url: payload.url,
fileNames: (payload.files || []).map((f) => f.name)
}));
if (mode === 'abort') throw new DOMException('Share canceled', 'AbortError');
if (mode === 'denied') throw new DOMException('Permission denied', 'NotAllowedError');
if (mode === 'data') throw new DOMException('Invalid files', 'DataError');
};
}, behaviour);
}
Two details matter. The recorded payload is reduced to plain data before storage, because File objects cannot cross the page.evaluate boundary and would silently serialise to {}. And the rejections are real DOMException instances, so a handler branching on err.name is exercised exactly as it will be in a browser.
Step 2 — Assert the Payload, Not Just the Call
Most share regressions are payload regressions: a URL that picked up a ?ref= parameter, a title that still says “Untitled”, a text field that duplicates the URL so the target shows it twice.
// share.spec.js
import { test, expect } from '@playwright/test';
import { stubShare } from './fixtures/share.js';
test('shares the canonical URL with a clean title', async ({ page }) => {
await stubShare(page);
await page.goto('/articles/secure-contexts/');
await page.getByRole('button', { name: /share/i }).click();
const calls = await page.evaluate(() => window.__shareCalls);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
title: 'Understanding Secure Context Requirements',
text: undefined,
url: 'https://www.example.com/articles/secure-contexts/',
fileNames: []
});
});
Asserting the whole object rather than individual fields is deliberate: it catches fields that were added by accident as well as fields that went missing.
Step 3 — Cover Every Settlement Path
Each rejection has a different correct response, and each deserves its own test.
test('a dismissal shows no error', async ({ page }) => {
await stubShare(page, 'abort');
await page.goto('/articles/secure-contexts/');
await page.getByRole('button', { name: /share/i }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button', { name: /share/i })).toBeEnabled();
});
test('a denied permission falls back to copying the link', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await stubShare(page, 'denied');
await page.goto('/articles/secure-contexts/');
await page.getByRole('button', { name: /share/i }).click();
await expect(page.getByRole('status')).toHaveText(/link copied/i);
});
The dismissal test asserts an absence, which is the whole point: AbortError is a user choice, not a failure, as handling AbortError when the share sheet is dismissed explains. Leaving the button enabled matters too — a flow that disables it on click strands the user after a dismissal.
Step 4 — Test the Unstubbed Fallback
Headless Chromium ships without a share implementation, so a test that installs no stub exercises the unsupported branch for free. This is the branch every desktop visitor takes, and the one most likely to rot.
test('falls back when navigator.share is absent', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/articles/secure-contexts/');
expect(await page.evaluate(() => typeof navigator.share)).toBe('undefined');
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/secure-contexts/');
});
Keep the explicit typeof assertion. If a future Playwright or Chromium release starts shipping a share implementation, this line fails loudly instead of the fallback test silently becoming a duplicate of the native one.
Failure Modes and Recovery
| Symptom | Cause | Minimal fix |
|---|---|---|
__shareCalls is empty |
Stub installed with page.evaluate after load |
Install through addInitScript before goto |
Recorded payload is {} |
File objects cannot cross the evaluate boundary |
Record plain fields and file names only |
err.name assertions never match |
Stub threw new Error('AbortError') |
Throw a real DOMException with the name as its second argument |
| Fallback test passes for the wrong reason | The stub leaked from a previous test | Install per test; never stub in a shared beforeAll |
| Clipboard assertions fail in CI | Permissions not granted to the context | context.grantPermissions(['clipboard-read', 'clipboard-write']) |
| Test green, production broken | Gesture lost to an await; stubs ignore activation |
Keep share() synchronous in the handler; verify on device |
Browser and Platform Caveat
These specs run identically across Playwright’s Chromium, Firefox, and WebKit builds, because the stub removes the browser’s own implementation from the equation — which is both the strength and the limit of the approach. What Playwright cannot observe is user activation: a stubbed share() never checks it, so the most common production failure, NotAllowedError from a call made after an await, cannot be reproduced here at all. Treat that as a structural rule enforced by code review and confirmed on hardware with remote DevTools. Playwright’s WebKit build is also not Safari; it shares the engine but not the OS integration, so its absence of a share implementation says nothing about real iOS behaviour.
FAQ
Why must the stub be installed with addInitScript rather than page.evaluate?
page.evaluate runs after the document has loaded, by which point your application module may already hold a reference to the original navigator.share or have decided that sharing is unsupported. addInitScript runs in every fresh document before any page script, so the replacement is in place before the application observes the API.
Can Playwright click a button inside the native share sheet?
No. The sheet is operating-system UI outside the browser’s rendering process, so it is invisible to every browser-automation protocol. Assert that navigator.share received the right payload and that each settlement path is handled; the sheet itself belongs to a manual device pass.
Does Playwright need a real user gesture for the share call to work?
A stubbed share function does not check user activation, so the test passes either way. That is precisely why gesture bugs escape Playwright: keep the invocation synchronous inside the click handler as a structural rule and verify activation behaviour on a physical device.