
Recommendation: rent two Macs — one per macOS version you need to cover — and run the same Playwright WebKit suite against each. Two Figment M-series Macs from $95/month each ($190/month total) is the direct-cost path; the Managed Mac Team at $999/month (two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support) is the right tier if you want the CI plumbing handled for you. Figment M-series plans are request-based: the exact chip, RAM, disk, macOS version per machine and SSH access are confirmed in the configuration quote at /mac-hosting/start. Verify current Safari/macOS pairings in Apple's Safari release notes before you commit.
You cannot downgrade macOS on a running machine to get an older Safari, and Apple does not ship older Safari installers for a newer OS. The only reliable way to hold two specific macOS/Safari combinations at once is to hold two separate machines.
Each macOS major version is paired with a Safari major version that updates along a single track. From Apple's Safari release notes as of 2026-11-10:
| Safari (latest in track) | Shipped | Typical paired macOS |
|---|---|---|
| Safari 27.2 | 2026-10-26 | Current macOS track |
| Safari 26.6 | 2026-07-27 | macOS Tahoe (26) |
| Safari 18.6 | 2025-07-29 | macOS Sequoia 15 |
| Safari 17.6 | 2024-07-29 | macOS Sonoma 14 |
| Safari 16.6 | 2023-07-24 | macOS Ventura 13 |
Confirm the exact Safari minor your rented Mac reports with safaridriver --version and sw_vers; do not assume the table above matches a specific image until the machine is provisioned. If you upgrade one Mac to the newest track, you lose the older environment — hence the two-machine approach.
Pull Safari OS-version share from your analytics for the last 90 days and pick the two non-current versions with the largest share. A compact one-liner against a CSV export:
# analytics-export.csv columns: date, path, ua_family, os_version
awk -F',' '$3 == "Safari" { print $4 }' analytics-export.csv \
| sort | uniq -c | sort -rn | head -10
Retire a dedicated test Mac for a version once it drops below roughly 2% of your Safari sessions. The marginal bugs it catches rarely justify the monthly cost at that point.
Two Figment M-series Macs from $95/month each totals from $190/month. $95 is the M-series entry price; the specific chip generation, RAM and disk come back on the quote and are not implied by the headline price. When you file the request at /mac-hosting/start, include:
Worked cost over a year, two self-managed M-series Macs: 2 × $95 × 12 = $2,280/year from Figment's entry price. The Managed Mac Team at $999/month totals $11,988/year, a $9,708 premium that buys two configured CI runners, team setup, backups, monitoring and one hour of monthly support. Only pick Managed Team if that premium is cheaper than what your team would otherwise spend building and maintaining the same pipeline.
If you only need a single Mac and plan to buy a full year upfront, a competing provider lists a lower annual: CloudMac publishes $99.99/month and $999/year for a dedicated M4/16 GB/256 GB on its pricing page and home page. That $999/year is $141 less than 12 × $95 for a specific M4/16 GB config, so if an M4/16 GB spec is exactly what you need and you are happy with one machine, price-check before you decide. Figment's $95 entry does not guarantee M4/16 GB — the quote names the exact hardware — and the Managed Team work is a different scope, not a hardware match.
Add your CI public key to each Figment Mac at provisioning (or append it to ~/.ssh/authorized_keys on the machine), then verify connectivity before wiring up the Makefile:
ssh -o BatchMode=yes -o ConnectTimeout=10 user@mac-sequoia.example.com \
'sw_vers -productVersion && /usr/bin/safaridriver --version'
ssh -o BatchMode=yes -o ConnectTimeout=10 user@mac-sonoma.example.com \
'sw_vers -productVersion && /usr/bin/safaridriver --version'
You should see the macOS version strings (15.x, 14.x) and the matching Safari build. If either times out, confirm Remote Login is on under System Settings → General → Sharing on that Mac, and that your network path reaches it.
The Makefile below uses rsync to push your repository to both Macs, ssh to run npx playwright test --project webkit on each, and rsync to pull reports back. Playwright's generic CI setup, including the --reporter flag and HTML report output path, is documented at playwright.dev/docs/ci.
# cross-os-safari.mk — run the same Playwright WebKit suite on two Macs
# Usage:
# MAC_V1=user@host1 MAC_V2=user@host2 TEST_URL=https://staging.example.com \
# make -f cross-os-safari.mk test-both
MAC_V1 ?= user@mac-sequoia.example.com
MAC_V2 ?= user@mac-sonoma.example.com
LABEL_V1 ?= macOS Sequoia 15 - Safari 18
LABEL_V2 ?= macOS Sonoma 14 - Safari 17
REMOTE_DIR := safari-cross-os
REPORT_DIR := reports
REPORT_V1 := $(REPORT_DIR)/v1
REPORT_V2 := $(REPORT_DIR)/v2
.PHONY: dirs sync-v1 sync-v2 test-v1 test-v2 test-both merge clean
dirs:
mkdir -p $(REPORT_V1) $(REPORT_V2)
sync-v1:
rsync -az --delete --exclude node_modules --exclude .git \
./ $(MAC_V1):$(REMOTE_DIR)/
ssh $(MAC_V1) "cd $(REMOTE_DIR) && npm ci --quiet && npx playwright install webkit"
sync-v2:
rsync -az --delete --exclude node_modules --exclude .git \
./ $(MAC_V2):$(REMOTE_DIR)/
ssh $(MAC_V2) "cd $(REMOTE_DIR) && npm ci --quiet && npx playwright install webkit"
test-v1: dirs sync-v1
ssh $(MAC_V1) "cd $(REMOTE_DIR) && TEST_URL='$(TEST_URL)' \
npx playwright test --project=webkit --reporter=html,json" \
|| { echo 'Mac V1 run failed'; exit 1; }
rsync -az $(MAC_V1):$(REMOTE_DIR)/playwright-report/ $(REPORT_V1)/
rsync -az $(MAC_V1):$(REMOTE_DIR)/test-results.json $(REPORT_V1)/results.json
test-v2: dirs sync-v2
ssh $(MAC_V2) "cd $(REMOTE_DIR) && TEST_URL='$(TEST_URL)' \
npx playwright test --project=webkit --reporter=html,json" \
|| { echo 'Mac V2 run failed'; exit 1; }
rsync -az $(MAC_V2):$(REMOTE_DIR)/playwright-report/ $(REPORT_V2)/
rsync -az $(MAC_V2):$(REMOTE_DIR)/test-results.json $(REPORT_V2)/results.json
# -j2 runs test-v1 and test-v2 in parallel
test-both:
$(MAKE) -f cross-os-safari.mk -j2 test-v1 test-v2
$(MAKE) -f cross-os-safari.mk merge
merge: $(REPORT_V1)/results.json $(REPORT_V2)/results.json
node scripts/merge-reports.js \
--v1 $(REPORT_V1) --label-v1 "$(LABEL_V1)" \
--v2 $(REPORT_V2) --label-v2 "$(LABEL_V2)" \
--out $(REPORT_DIR)/comparison.html
@echo "Open $(REPORT_DIR)/comparison.html"
clean:
rm -rf $(REPORT_DIR)
The --reporter=html,json output path and the test-results.json filename follow Playwright's documented defaults; configure reporter in playwright.config.ts if you have customised them. The example is illustrative — run it against your own project and read the exit codes, do not treat a green make as a benchmark.
scripts/merge-reports.js reads both JSON result files, pairs specs by title, and writes one HTML table linking to each full report:
#!/usr/bin/env node
'use strict';
const { readFileSync, writeFileSync } = require('fs');
const { join } = require('path');
const args = process.argv.slice(2);
const get = (f) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : null; };
const v1dir = get('--v1');
const v2dir = get('--v2');
const labelV1 = get('--label-v1') || 'V1';
const labelV2 = get('--label-v2') || 'V2';
const outFile = get('--out') || 'comparison.html';
const v1 = JSON.parse(readFileSync(join(v1dir, 'results.json'), 'utf8'));
const v2 = JSON.parse(readFileSync(join(v2dir, 'results.json'), 'utf8'));
function flatten(suites, path = []) {
const out = [];
for (const s of suites || []) {
const next = [...path, s.title].filter(Boolean);
for (const sp of s.specs || []) {
out.push({ key: [...next, sp.title].join(' > '), ok: sp.ok });
}
out.push(...flatten(s.suites, next));
}
return out;
}
const map1 = new Map(flatten(v1.suites).map((r) => [r.key, r.ok]));
const map2 = new Map(flatten(v2.suites).map((r) => [r.key, r.ok]));
const keys = [...new Set([...map1.keys(), ...map2.keys()])].sort();
const cell = (v) => v === undefined ? '-' : v ? 'PASS' : 'FAIL';
const rows = keys
.map((k) => `<tr><td>${k}</td><td>${cell(map1.get(k))}</td><td>${cell(map2.get(k))}</td></tr>`)
.join('\n');
writeFileSync(outFile, `<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Safari Cross-OS Comparison</title>
<style>body{font-family:system-ui,sans-serif;margin:2rem}
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:.4rem .75rem}
th{background:#f4f4f4}td:nth-child(2),td:nth-child(3){text-align:center;font-weight:600}</style>
</head><body>
<h2>Safari Cross-OS Comparison</h2>
<p>Generated ${new Date().toISOString()}</p>
<table><thead><tr><th>Test</th><th>${labelV1}</th><th>${labelV2}</th></tr></thead>
<tbody>${rows}</tbody></table>
<p>Full reports: <a href="v1/index.html">${labelV1}</a> ·
<a href="v2/index.html">${labelV2}</a></p>
</body></html>`);
console.log('Written:', outFile);
Any test that is PASS in one column and FAIL in the other is an OS-level Safari regression worth filing immediately — those are the bugs you cannot reproduce without a dedicated older-OS Mac.
Archive reports/comparison.html as a dated CI artifact per build. When a user reports a scroll or layout bug in an older Safari, you can grep historical artifacts for the first build where the row flipped from PASS to FAIL, which is far faster than reproducing against a one-off rental.
Re-run the analytics share check every quarter. When a covered macOS version drops below 2% of your Safari sessions, cancel that Mac and redirect the ~$95/month to coverage on whatever Safari track has climbed in its place.
Start a configuration request at /mac-hosting/start or review the plans at /mac-hosting.