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.
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.
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 |
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.