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.

Where the Stub Lands in the Document Lifecycle A lifecycle bar for a new document. addInitScript executes before any page script, so the application sees the stubbed API. page.evaluate executes after load, by which time the application has already read navigator.share and cached its support decision. addInitScript stub installed page scripts eval reads navigator.share load event UI interactive page.evaluate too late Stub visible to the app native branch runs · payload recorded Support already decided fallback ran · stub never called

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.

Payload Regressions a Whole-Object Assertion Catches The expected payload sits on the left with a clean title and canonical URL. Three regression cards on the right show a tracking parameter appended to the URL, a placeholder title, and a text field duplicating the URL — each caught by comparing the entire recorded object. Expected payload title: "Understanding Secure Context Requirements" text: undefined url: "https://www.example.com /articles/secure-contexts/" fileNames: [] URL drift …/secure-contexts/?ref=share-button&utm_source=app Stale title title: "Untitled" — template default never replaced Duplicated field text repeats url — target renders the link twice

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.

What the Playwright Suite Covers Five share outcomes across two columns. Native success, dismissal, permission denial and the unsupported fallback are all covered by Playwright specs. User-activation loss and the rendering of the native sheet remain uncovered and must be checked on hardware. Covered by Playwright native success — payload asserted exactly AbortError — no alert, button stays enabled NotAllowedError — clipboard fallback runs API absent — unsupported branch runs Device pass only user activation — stubs never check it, so an await before share() passes here the sheet itself — OS UI, invisible to every automation protocol target-specific DataError on files — only real share targets reject them Stub Scope and Test Isolation Installing the stub in a shared beforeAll leaks it into the unsupported-browser test, which then passes for the wrong reason. Installing it inside each test that needs it keeps the unstubbed fallback test genuinely unstubbed. stub in beforeAll every test sees a share API the fallback spec never runs its branch green suite, broken desktop the most expensive kind of false confidence stub per test native specs opt in explicitly fallback spec asserts the API is absent both branches genuinely covered order-independent and readable

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.