
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.
The constraints come from WebKit's launch post, Meet Web Push:
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.localhost.*.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.
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.
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.
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.
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.
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.
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 option | Price | Notes for this test |
|---|---|---|
| Virtual Intel macOS | $29/month | 2 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/month | Collects requests until supply confirms. |
| M-series Mac | from $95/month | Request 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.
localhost with mkcert)VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in CI secrets, not in the repo/, with a push event handler*.push.apple.com allowed on the runnerweb-push installed (npm install web-push)screencapture available (shipped with macOS)EVIDENCE_DIR collected as a CI artifact on failure