CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/web-push-tests

Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.

74

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files
name:
web-push-tests
description:
Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.
metadata:
{"keywords":"web-push, vapid, push-api, rfc-8030, pushsubscriptionchange"}

web-push-tests

Overview

Per mdn-push, the Push API is "Baseline Widely available" across browsers since March 2023. Firefox imposes per-app push quotas; Chrome does not. This skill tests the browser side (pushManager.subscribe per w3c-push, push event, pushsubscriptionchange, unsubscribe) plus the server-side push protocol per rfc8030 and VAPID auth per rfc8292. Cross-channel push including native APNs / FCM is push-notification-test-author territory.

When to use

  • A PWA implements web-push and needs a subscription-lifecycle test before each release.
  • Users report "I subscribed but never got a push" - author the endpoint-expiry test that catches stale subscriptions.
  • A VAPID key was rotated and the team needs evidence subscriptions survived (they should, since VAPID identifies the server per rfc8292, not the subscription).
  • Compliance audit needs evidence revoked subscriptions are cleaned up server-side on 410 Gone per rfc8030.

Authoring

Step 1 - Set up the Playwright fixture with notification permission

Web-push subscription requires the notifications permission per w3c-push userVisibleOnly: true requirement (and per browser UX gating). Grant it in the fixture:

import { test as base, expect, chromium } from '@playwright/test';

const test = base.extend({
  context: async ({ playwright }, use) => {
    const ctx = await playwright.chromium.launchPersistentContext(
      './tmp/user-data',
      {
        permissions: ['notifications'],
        headless: false, // SW + push prompt path needs headed mode in some Chromium builds
      }
    );
    await use(ctx);
    await ctx.close();
  },
  page: async ({ context }, use) => {
    const page = await context.newPage();
    await use(page);
  },
});

// Shared: resolve the active service worker - reused across the tests below.
async function swReady(context) {
  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');
  return sw;
}

export { test, expect };

Per w3c-push, userVisibleOnly scopes the subscription to user-visible push messages; most browsers require it to be true.

Step 2 - Test subscription creation

test('pushManager.subscribe returns a PushSubscription with VAPID-bound keys', async ({ page, context }) => {
  await page.goto('https://localhost:3000/');

  const sw = await swReady(context);

  const sub = await page.evaluate(async (vapidPubKey) => {
    const reg = await navigator.serviceWorker.ready;
    const s = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: vapidPubKey,
    });
    return s.toJSON();
  }, process.env.VAPID_PUBLIC_KEY_BASE64URL!);

  // Per w3c-push: PushSubscription exposes endpoint + keys (p256dh, auth)
  expect(sub.endpoint).toMatch(/^https?:\/\//);
  expect(sub.keys?.p256dh).toBeTruthy();
  expect(sub.keys?.auth).toBeTruthy();
  // expirationTime is optional per w3c-push - null is allowed
  expect(sub.expirationTime === null || typeof sub.expirationTime === 'number').toBe(true);
});

Per w3c-push, the PushSubscription exposes:

PropertyPer w3c-push
endpoint"URL where the application server sends messages"
expirationTime"Optional timestamp (milliseconds since epoch) indicating when the subscription expires"
keys.p256dh"P-256 ECDH Diffie-Hellman public key"
keys.auth"authentication secret that an application server uses"
optionsThe PushSubscriptionOptions that created it

Step 3 - Test getSubscription() returns the same endpoint

test('PushManager.getSubscription returns the active subscription', async ({ page }) => {
  await page.goto('https://localhost:3000/');

  // ... subscribe as Step 2 ...

  const same = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    const s = await reg.pushManager.getSubscription();
    return s?.endpoint;
  });

  expect(same).toBeTruthy();
});

Per mdn-push, PushManager.getSubscription() returns the active subscription if one exists, else null.

Step 4 - Test the SW push event with PushMessageData

Drive a push event into the registered service worker. In a real test environment, the application server sends to the endpoint; for an isolated unit test of the SW handler, dispatch the event synthetically:

test('SW push event triggers showNotification', async ({ context, page }) => {
  await page.goto('https://localhost:3000/');
  const sw = await swReady(context);

  // Inject a tracker for the call
  await sw.evaluate(() => {
    (self as any).__showNotificationCalls = [];
    const real = self.registration.showNotification.bind(self.registration);
    self.registration.showNotification = ((title: string, options?: any) => {
      (self as any).__showNotificationCalls.push({ title, options });
      return real(title, options);
    }) as any;
  });

  // Dispatch a synthetic PushEvent - Note: real Chromium dispatches via the push service.
  // For isolated SW unit tests, use the `push` event listener manually.
  await sw.evaluate(() => {
    const e: any = new Event('push');
    e.data = {
      arrayBuffer: () => new TextEncoder().encode('{"title":"hi","body":"x"}').buffer,
      blob: () => new Blob([new TextEncoder().encode('{"title":"hi","body":"x"}')]),
      bytes: () => new TextEncoder().encode('{"title":"hi","body":"x"}'),
      json: () => ({ title: 'hi', body: 'x' }),
      text: () => '{"title":"hi","body":"x"}',
    };
    self.dispatchEvent(e);
  });

  const calls = await sw.evaluate(() => (self as any).__showNotificationCalls);
  expect(calls.length).toBeGreaterThanOrEqual(1);
  expect(calls[0].title).toBe('hi');
});

Per w3c-push, PushMessageData exposes:

MethodReturns
arrayBuffer()"the raw bytes"
blob()"Wraps bytes in a Blob object"
bytes()"a Uint8Array view"
json()"Parses as JSON"
text()"Decodes UTF-8"

Step 5 - Test pushsubscriptionchange

Per w3c-push, pushsubscriptionchange fires when subscriptions refresh, revoke, or expire outside app control, passing oldSubscription and newSubscription. The handler must re-subscribe and POST the new endpoint to the app server:

test('pushsubscriptionchange triggers re-subscription', async ({ context, page }) => {
  await page.goto('https://localhost:3000/');
  const sw = await swReady(context);

  await sw.evaluate(() => {
    (self as any).__resubCalls = [];
    self.addEventListener('pushsubscriptionchange', (e: any) => {
      (self as any).__resubCalls.push({
        old: e.oldSubscription?.endpoint,
        new: e.newSubscription?.endpoint,
      });
    });
  });

  // Synthetic dispatch - real fires when the push service expires the endpoint
  await sw.evaluate(() => {
    const e: any = new Event('pushsubscriptionchange');
    e.oldSubscription = { endpoint: 'https://push.example/old' };
    e.newSubscription = { endpoint: 'https://push.example/new' };
    self.dispatchEvent(e);
  });

  const calls = await sw.evaluate(() => (self as any).__resubCalls);
  expect(calls[0]).toEqual({
    old: 'https://push.example/old',
    new: 'https://push.example/new',
  });
});

The real-world trigger for pushsubscriptionchange is the push service revoking / refreshing the endpoint per w3c-push - not testable headlessly without the push service in the loop, which is why Step 6 covers the server-side expiry signal instead.

Step 6 - Server-side: push-service responses

On 410 Gone the application server must delete the endpoint from its store per rfc8030; 413 means shrink the payload, 429 means back off. The full response-code table and the cleanup-on-410 Vitest test are in references/push-protocol.md.

Step 7 - VAPID JWT shape

The application server signs an ES256 JWT and sends Authorization: vapid t=<JWT>, k=<key> per rfc8292. The JWT must carry aud (push origin), exp (MUST NOT exceed 24h), and sub (mailto: or https: contact). The claim table and the assertion test are in references/push-protocol.md.

Step 8 - Test unsubscribe()

Per w3c-push, PushSubscription.unsubscribe() "deactivates a subscription, returning a promise resolving to true on success or false if already deactivated":

test('unsubscribe() resolves true and getSubscription returns null after', async ({ page }) => {
  await page.goto('https://localhost:3000/');
  // ... subscribe ...

  const result = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    const s = await reg.pushManager.getSubscription();
    const ok = await s!.unsubscribe();
    const after = await reg.pushManager.getSubscription();
    return { ok, hasAfter: !!after };
  });

  expect(result.ok).toBe(true);
  expect(result.hasAfter).toBe(false);
});

Running

Locally

# 1. Generate a VAPID key pair (the `web-push` CLI helper)
npx web-push generate-vapid-keys
# Export the public key as VAPID_PUBLIC_KEY_BASE64URL

# 2. Run the subscription-lifecycle suite
npx playwright test tests/web-push.spec.ts

# 3. Run the cleanup-on-410 unit suite
npx vitest run tests/subscription-cleanup.spec.ts

In CI

jobs:
  web-push:
    runs-on: ubuntu-latest
    env:
      VAPID_PUBLIC_KEY_BASE64URL: ${{ secrets.VAPID_PUB }}
      VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIV }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci && npx playwright install --with-deps chromium
      - run: npx playwright test tests/web-push.spec.ts
      - run: npx vitest run tests/subscription-cleanup.spec.ts

Inject VAPID keys via secret env vars per rfc8292 - the public key only is needed in the browser, but the private signs the JWT and must never reach the client.

Parsing results

A successful subscription test produces:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/eFwxc...",
  "expirationTime": null,
  "keys": {
    "p256dh": "BPq8...32-byte-x9.62...",
    "auth": "abc...16-byte-secret..."
  }
}

The endpoint host indicates the push service:

  • https://fcm.googleapis.com/fcm/send/* → Chrome / Edge / Brave (Google FCM)
  • https://updates.push.services.mozilla.com/* → Firefox
  • https://*.notify.windows.com/* → Edge Legacy (rare today)

A 410 Gone from any of these = the subscription should be deleted server-side per rfc8030.

CI integration

For projects that ship web-push: gate PRs on the subscription suite + cleanup suite. Skip the live-send-to-push-service step in PR-level CI (introduces flakiness from real push services); gate release builds on a smoke test that does send through the real service.

- name: PR-level web-push tests
  run: |
    npx playwright test tests/web-push.spec.ts
    npx vitest run tests/subscription-cleanup.spec.ts

- name: Release-level live push smoke
  if: github.event_name == 'release'
  run: npx vitest run tests/live-push.spec.ts

Anti-patterns

Anti-patternWhy it failsFix
Hardcode VAPID public key in test fixturesRotation breaks testsInject via env (Step 2)
Skip the 410 Gone cleanup testSubscription store grows with dead endpoints; push-service rate-limit riskStep 6
Assume expirationTime is always non-nullPer w3c-push it's "Optional"; Chrome usually returns nullStep 2 accepts both branches
Test pushsubscriptionchange by waiting for real expiryHours-to-months wait; not feasible in CISynthetic dispatch (Step 5)
Use userVisibleOnly: falseMost browsers reject; per w3c-push the flag means "made visible to the user"Always true in tests (Step 1)
Skip permission grant in the fixturesubscribe() rejects silently with PermissionDeniedErrorpermissions: ['notifications'] (Step 1)
Treat all 4xx from the push service the same410 means delete; 413 means shrink; 429 means back off - per rfc8030Step 6 maps each
Sign VAPID JWTs with exp longer than 24hPer rfc8292 exp "MUST NOT be more than 24 hours" - push service rejectsStep 7 asserts the cap
Test only one push service endpointFCM vs Mozilla vs others behave differently; multi-browser test matrixRun on Chromium + Firefox (Mozilla push service)

Limitations

  • Synthetic pushsubscriptionchange and push event dispatch is an approximation. The real path runs through the push service per w3c-push; tests that need full fidelity must spin up a push-service-compatible mock (e.g. a web-push-testsing-service local instance).
  • Firefox push quota per mdn-push: "Limited quota per app (except notifications); refreshes on site visit" - tests asserting unlimited sends will fail on Firefox.
  • Endpoint host pinning is brittle. The push service hostname varies by browser and may rotate within a browser; tests should match ^https?:// not specific hostnames.
  • The push payload encryption per RFC 8291 is opaque to the browser-side test - PushMessageData.text() returns the decrypted payload. Server-side tests need the encryption library (web-push npm package) to validate the wire format.
  • getSubscription() may return null after a browser restart even when the user had previously subscribed - local browser state can clear without firing pushsubscriptionchange. Tests that assume subscription persistence across runs must re-subscribe defensively.
  • Cross-channel push (mobile APNs / FCM, native iOS / Android SDKs) is not in scope here. See push-notification-test-author for those flows. This skill is browser-only.

References

  • W3C Push API spec (PushManager, PushSubscription, PushMessageData methods, pushsubscriptionchange) - w3c-push.
  • MDN Push API (Baseline status, Firefox quota notes, security considerations) - mdn-push.
  • RFC 8030 - Web Push Protocol (TTL header, Urgency header, 201/410/413/429 response codes) - rfc8030.
  • RFC 8292 - VAPID for Web Push (ES256 JWT, aud/exp/sub claims, Authorization header format) - rfc8292.
  • web.dev - Notifications (the showNotification options reference Step 4 paired with) - web-dev-notifications.
  • Differentiation: push-notification-test-author covers cross-channel push (Web Push + APNs + FCM in one suite). This skill covers only browser web-push subscription lifecycle: the four-call subscribe / push-event / change / unsubscribe contract per w3c-push.
  • Sibling skills: pwa-install-flow-reference (notification permission gates parallel to install gates), service-worker-lifecycle-tests (the SW must be active for push events).
Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/web-push-tests badge