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 Is Testable in a Share Flow Three stacked bands. The first band, the routing decision, is fully covered by fast unit tests. The second band, the invocation of navigator.share with a payload, is covered by browser tests using a stub. The third band, the operating system share sheet, is reachable only by manual testing on a physical device. 1 · Routing decision isSecureContext · typeof navigator.share · canShare(payload) Unit tests milliseconds · exhaustive 2 · Invocation gesture handler calls share(payload) · promise settles Browser tests stubbed API · real DOM 3 · Native share sheet OS process · outside the browser · not scriptable Manual only physical device checklist

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://localhost counts, 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:test with 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');
  });
});
Environment Matrix for Routing Tests A four-row table. Each row lists a simulated environment — insecure origin, share absent, canShare returns false, and all checks pass — alongside the route and reason the decision function must return for it. Simulated environment Route reason isSecureContext = false fallback insecure-context navigator.share = undefined fallback unsupported canShare(payload) = false fallback invalid-payload all checks pass 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 await before 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.
Remote Inspection Routes for Device Testing Two device columns. An Android phone running Chrome connects over USB to chrome://inspect on the desktop; an iPhone running Safari connects to the Develop menu in desktop Safari. Both feed into a shared list of four checks: the sheet opens, dismissal is silent, the promise settles, and the fallback engages when unsupported. Android · Chrome USB + developer mode iOS · Safari Web Inspector enabled chrome://inspect full DevTools session Develop → device Safari Web Inspector Confirm on device · sheet opens on tap 1 · dismissal is silent · promise settles · fallback engages · offline share queues Sheet itself: never scriptable

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.