Simulating Unsupported Browsers in Tests

This guide is part of Testing and Debugging Web Share Flows, which itself is part of Web Share API and Security Contexts.

“Unsupported” is not one state. A browser can lack navigator.share entirely, expose share but not canShare, support both while rejecting your particular payload, or support everything while running on an origin that is not a secure context. Each produces a different code path, and each is a real configuration somewhere in your traffic. Testing only the first one leaves three ways for the share button to die quietly.

Feature Detection Gate

The gate under test is the same ordered sequence used across the site — context, then existence, then payload validity.

// share-availability.js
export function shareAvailability(payload, env = globalThis) {
  if (env.isSecureContext !== true) return 'insecure-context';
  if (typeof env.navigator?.share !== 'function') return 'no-share';
  if (typeof env.navigator.canShare !== 'function') {
    return payload?.files?.length ? 'no-canshare-with-files' : 'available';
  }
  return env.navigator.canShare(payload) ? 'available' : 'payload-rejected';
}

Returning a string rather than a boolean is what makes exhaustive testing possible: each unsupported shape has a name, so a test can assert which kind of unsupported it detected, and the UI can explain itself accordingly.

Four Shapes of Unsupported Four cards describe distinct failure shapes: an insecure origin where the API is hidden, a browser without any share function, a browser exposing share but not canShare, and a supported browser rejecting a specific payload. Each card names the real environment where it occurs. insecure-context Page served over http:// or a LAN IP. API hidden even on a supporting browser. Seen when testing from a phone on the local network. no-share navigator.share is undefined. Firefox desktop, Chrome on Linux, every headless Chromium build, jsdom. no-canshare-with-files share exists, canShare does not. Older Safari — an unconditional canShare() call throws TypeError here. payload-rejected Full support, but canShare(payload) returns false for these files or fields. Most common with file payloads on desktop.

Step 1 — Describe Every Shape as Data

A table-driven test covers all four states in one readable block and fails with the name of the case that broke.

// share-availability.test.js
import { describe, it, expect } from 'vitest';
import { shareAvailability } from './share-availability.js';

const base = { isSecureContext: true, navigator: { share: async () => {}, canShare: () => true } };
const file = new File(['x'], 'note.txt', { type: 'text/plain' });

const cases = [
  ['insecure origin',      { ...base, isSecureContext: false },                          {},             'insecure-context'],
  ['no share function',    { ...base, navigator: {} },                                   {},             'no-share'],
  ['share without canShare', { ...base, navigator: { share: async () => {} } },          { files: [file] }, 'no-canshare-with-files'],
  ['payload rejected',     { ...base, navigator: { share: async () => {}, canShare: () => false } }, { files: [file] }, 'payload-rejected'],
  ['fully supported',      base,                                                          { url: '/a' },  'available']
];

describe('shareAvailability', () => {
  it.each(cases)('%s → %s', (_name, env, payload, expected) => {
    expect(shareAvailability(payload, env)).toBe(expected);
  });
});

The third row is the one teams usually miss. A browser exposing share without canShare is not hypothetical — it shipped in Safari, and code that calls canShare unconditionally throws a TypeError there while the browser is perfectly capable of sharing text.

Step 2 — Force the Shape in a Real Browser

Unit tests prove the decision; a browser test proves the UI reacts. Deleting the property through an init script gives you an end-to-end unsupported run without changing the application code.

// unsupported.spec.js — Playwright
import { test, expect } from '@playwright/test';

test('shows the copy-link fallback when sharing is unavailable', async ({ page, context }) => {
  await context.grantPermissions(['clipboard-read', 'clipboard-write']);

  await page.addInitScript(() => {
    delete Navigator.prototype.share;
    delete Navigator.prototype.canShare;
  });

  await page.goto('/articles/secure-contexts/');
  await page.getByRole('button', { name: /share/i }).click();

  await expect(page.getByRole('status')).toHaveText(/link copied/i);
});

Deleting from Navigator.prototype rather than the instance matters, because that is where the property actually lives; deleting navigator.share on the instance leaves the inherited accessor in place and the page still detects support.

Simulating an insecure origin needs a different lever, since a page served from localhost genuinely is a secure context:

test('routes to the fallback when the context is not secure', async ({ page }) => {
  await page.addInitScript(() => {
    Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  });

  await page.goto('/articles/secure-contexts/');
  await page.getByRole('button', { name: /share/i }).click();
  await expect(page.getByRole('status')).toBeVisible();
});

Step 3 — Keep the Branch Covered Forever

Unsupported paths rot because nobody looks at them. Two cheap habits prevent it: assert the absence of support at the top of every fallback test, so a leaked stub cannot mask the branch, and keep at least one CI job that runs the whole suite with sharing forcibly removed.

// vitest.setup.js — used by the "no-share" CI project
import { beforeEach } from 'vitest';

beforeEach(() => {
  delete globalThis.navigator?.share;
  delete globalThis.navigator?.canShare;
});

Running the full suite in this mode answers a question no single test can: does anything at all break when sharing does not exist? It routinely surfaces components that render a Share button unconditionally, or analytics that assume a shared outcome.

Two CI Projects, One Suite The same specs run twice. The default project keeps stubs available so native paths are exercised, while a second project deletes share and canShare before every test so the entire application is verified under an unsupported browser. one spec suite project: default stubs installed per test native paths + rejections covered project: no-share share + canShare deleted globally whole app verified without the API

Designing the UI Around the Result

Simulating unsupported states is only half the work; the other half is making sure the interface has something sensible to say in each of them. A single generic “Sharing is not available” message wastes the distinction the availability function just made.

An insecure-context result is a developer-facing bug, never a user-facing message — in production it should be impossible, so log it loudly and route to the fallback silently. A no-share result is permanent for that browser, so the button should simply be the fallback: label it “Copy link” rather than offering Share and then substituting something else after the tap. A payload-rejected result is specific to this content, so the same page may share text fine and reject a file; the honest UI keeps sharing available and drops the attachment with a one-line explanation.

// share-button-label.js
export function shareButtonLabel(availability) {
  switch (availability) {
    case 'available':               return { label: 'Share', icon: 'share' };
    case 'payload-rejected':        return { label: 'Share link only', icon: 'share' };
    case 'no-canshare-with-files':  return { label: 'Share link only', icon: 'share' };
    default:                        return { label: 'Copy link', icon: 'copy' };
  }
}

Deciding the label from availability before first paint also removes a jarring behaviour users notice immediately: a Share button that turns into a copy confirmation only after they tap it. The progressive enhancement guidance treats this as a rule — the control should describe what will actually happen.

Failure Modes and Recovery

Symptom Cause Minimal fix
Page still detects support after delete navigator.share Property lives on Navigator.prototype Delete from the prototype in an init script
TypeError: canShare is not a function Unconditional canShare call Guard with typeof before calling
Insecure-context branch never covered localhost is genuinely secure Override window.isSecureContext in a test-only script
Fallback test passes with support present Stub leaked from an earlier test Assert typeof navigator.share === 'undefined' first
Share button rendered with no working action Component ignores availability Render from the availability result, not unconditionally
Delete From the Prototype, Not the Instance navigator.share is defined on Navigator.prototype rather than on the navigator instance, so deleting it from the instance removes nothing and the page still detects support. Deleting from the prototype is what makes the simulation real. delete navigator.share the property is not an own property the inherited accessor still resolves typeof still returns "function" the test proves nothing delete Navigator.prototype.share removes it where it is defined typeof returns "undefined" the page takes the fallback branch an honest unsupported environment

Browser and Platform Caveat

The simulated states map onto real traffic rather than edge cases: no-share is most of desktop Firefox and Linux Chrome, payload-rejected is what desktop browsers return for file payloads they cannot forward, and insecure-context is what every developer hits the first time they open a dev server from a phone. What simulation cannot reproduce is a platform that reports support and then fails at the OS layer — a share target that refuses a file type, for example. Those surface only on hardware, so pair this suite with the device pass in debugging navigator.share with remote DevTools, and route every non-available result through the same copy-to-clipboard fallback so users never meet a dead button.

FAQ

Which browsers actually lack navigator.share?

Firefox on desktop, Chrome on Linux, and all headless Chromium builds generally have no share implementation, and any browser on an insecure origin behaves as though the API is missing. That is a large share of desktop traffic, which is why the fallback branch deserves the same test rigour as the native one.

Is a browser with share but no canShare a real combination?

Yes. Earlier Safari releases shipped navigator.share before navigator.canShare, so code that calls canShare unconditionally throws a TypeError on a browser that fully supports text sharing. Simulate it by stubbing only share and asserting your code still routes correctly.

How do I simulate an insecure origin without deploying over HTTP?

In unit tests pass isSecureContext: false on the injected environment object. In a browser test, override window.isSecureContext through an init script with Object.defineProperty; you cannot make a real localhost page insecure, but you can make the code observe an insecure context.