Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Testing Safari Web Push Notifications on macOS

Safari adopted the cross-browser Web Push standard in Safari 16.1 on macOS Ventura, and the only way to test it end to end is on a real macOS machine that can register with Apple's webpushd daemon and route APNs-backed payloads into Notification Center. This page walks through a working VAPID push, a Safari subscription, and a screenshot-backed assertion you can wire into CI. Run it on a Figment Mac so the banner actually appears.

Safari Web Push requirements

The constraints come from WebKit's launch post, Meet Web Push:

  • Safari 16.1+ on macOS Ventura or later. On iOS and iPadOS, Web Push arrived in 16.4 only for sites added to the Home Screen; see Web Push for Web Apps on iOS and iPadOS.
  • Explicit user gesture. Notification.requestPermission() must run inside a click or tap handler. On-load prompts are ignored.
  • userVisibleOnly: true. Silent push is not permitted; every push must render a visible notification.
  • Secure context. Service worker registration and the Push API require HTTPS or localhost.
  • Egress to *.push.apple.com. If your runner has outbound firewall rules, allow URLs from any subdomain of push.apple.com.

Pin your target Safari version against the Safari release notes before assuming newer features such as Declarative Web Push; the plain VAPID flow below works from Ventura through Sequoia.

Generate VAPID keys

VAPID lets your server identify itself to Apple's push service. Generate the pair once per application using the web-push library:

npm install -g web-push
web-push generate-vapid-keys --json

Store the private key in your CI secret manager as VAPID_PRIVATE_KEY. The public key is safe in client JavaScript and is passed to pushManager.subscribe as applicationServerKey.

Push sender script

This Node script reads a subscription JSON produced by the browser step and sends one payload. It exits non-zero on failure so a CI step stops on delivery errors.

// send-push.js
// Usage: node send-push.js <subscription.json> "<message>"
'use strict';

const webpush = require('web-push');
const fs = require('fs');

const [,, subscriptionFile, message = 'Hello from Safari Web Push test'] = process.argv;
if (!subscriptionFile) {
  console.error('Usage: node send-push.js <subscription.json> "<message>"');
  process.exit(1);
}

const subscription = JSON.parse(fs.readFileSync(subscriptionFile, 'utf8'));

webpush.setVapidDetails(
  'mailto:ci@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

const payload = JSON.stringify({
  title: 'Web Push Test',
  body: message,
  icon: '/icon-192.png',
});

webpush.sendNotification(subscription, payload, { TTL: 60 })
  .then((result) => {
    console.log('Push sent, status:', result.statusCode);
  })
  .catch((err) => {
    console.error('Push failed:', err.statusCode, err.body);
    process.exit(1);
  });

Secret values stay in process.env; the script never writes them to disk or logs.

Subscribe from a real Safari page

Playwright's bundled WebKit build is useful for driving pages, but it is not Safari.app and does not talk to webpushd. Capture the subscription from Safari itself (via AppleScript or open -a Safari with a test harness page), then let Playwright assert page-level receipt. The pattern below opens Safari, captures the subscription the page writes to disk, and hands it to the sender script.

// safari-push.spec.js
// Prereq: Safari is open on the test page, which POSTs its subscription
// to a local writer endpoint saving <sub>.json. Permission was granted
// once interactively and is remembered in the Safari profile.
const { test, expect } = require('@playwright/test');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

test('VAPID push reaches Notification Center', async () => {
  const subPath = path.join(process.env.RUNNER_TEMP || '/tmp', 'safari-sub.json');

  // The page under test wrote this file after pushManager.subscribe()
  expect(fs.existsSync(subPath), 'Safari subscription JSON present').toBe(true);

  execSync(`node send-push.js ${JSON.stringify(subPath)} "Regression test push"`, {
    stdio: 'inherit',
  });

  // Give webpushd time to route the payload and render the banner
  execSync('sleep 10');

  const evidence = `/tmp/push-evidence/banner_${Date.now()}.png`;
  fs.mkdirSync('/tmp/push-evidence', { recursive: true });
  execSync(`screencapture -x ${evidence}`);
  console.log('Banner screenshot:', evidence);
});

The service worker in your app can also postMessage the received payload to a page the test polls, giving a second, non-visual delivery receipt independent of the screenshot.

Capture banner evidence

screencapture -x suppresses the shutter sound and writes a PNG of the current desktop, which is where Notification Center banners render on macOS. Keep the artifact for at least seven days; banners disappear quickly and this file is the durable proof a delivery actually hit the screen.

#!/usr/bin/env bash
set -euo pipefail
EVIDENCE_DIR="${EVIDENCE_DIR:-/tmp/push-evidence}"
mkdir -p "$EVIDENCE_DIR"
sleep 10
screencapture -x "$EVIDENCE_DIR/banner_$(date +%Y%m%d_%H%M%S).png"

Upload $EVIDENCE_DIR as a CI artifact on failure.

Clear notification state between runs

Stale banners accumulate and will confuse human triage of a failure. Between runs:

killall NotificationCenter 2>/dev/null || true
sleep 2

launchd relaunches NotificationCenter; displayed banners are cleared. This does not drop undelivered payloads that webpushd has queued.

Which Figment Mac fits this test

Web Push on Safari needs a real macOS host, with webpushd running and outbound HTTPS to *.push.apple.com. All three Figment options qualify technically:

Figment optionPriceNotes for this test
Virtual Intel macOS$29/month2 vCPU, 8 GiB RAM, 80 GiB disk; browser-console access; Intel macOS Sequoia being installed. Safari + web-push run fine for a single-tab regression test.
i3 Mac$49/monthCollects requests until supply confirms.
M-series Macfrom $95/monthRequest a quote for exact chip, RAM and disk. Use this when you also need arm64 Xcode 26-or-earlier builds alongside the push test.

For comparison, CloudMac lists an M4 mini at $99.99/month with a $999/year annual rate (effectively $83.25/month) on the 256 GB tier; the 512 GB and 1 TB tiers show as fully reserved. Figment's $29 virtual Intel macOS is a lower entry price, not the same hardware as an M4 mini; a specific M4/16 GB/256 GB configuration is not quoted at $95 and is confirmed per order. Managed Build Mac at $249/month adds one M-series machine with CI setup, backups, monitoring and 30 minutes of monthly support; Managed Mac Team at $999/month covers two machines, two CI runners, team setup, backups, monitoring and one hour of monthly support. Tax and fees are whatever the quote states.

Start a configuration at /mac-hosting/start or review the plan list at /mac-hosting.

Pre-flight checklist

  • HTTPS origin reachable from the Mac (or localhost with mkcert)
  • VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in CI secrets, not in the repo
  • Service worker registered at scope /, with a push event handler
  • Outbound traffic to *.push.apple.com allowed on the runner
  • Permission granted once in Safari and preserved in the browser profile
  • web-push installed (npm install web-push)
  • screencapture available (shipped with macOS)
  • EVIDENCE_DIR collected as a CI artifact on failure

Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms