
Recommendation: add a Playwright WebKit test that records window.scrollY after each scrollIntoView() call to a per-browser JSON baseline, then asserts within ±5px tolerance on every subsequent run. For final sign-off against the real Safari app on macOS, run the same spec on a Figment Mac. The $29 virtual Intel macOS plan runs Intel Safari on its installed macOS release and is enough for most scroll-position checks; a Figment M-series Mac (from $95/month, with exact chip, RAM and macOS version confirmed in the quote) is the right pick when you need the current Apple-silicon build of Safari or more parallelism in CI. Start a configuration at /mac-hosting/start.
Safari's scroll implementation has edge cases that Chromium-only tests will miss: smooth scroll can stop short when content grows mid-animation, snap targets can resolve differently across releases, and scrollIntoView block alignment for very large elements has drifted between versions. Without a numeric scroll-position assertion on every build, a regression ships invisibly — the page looks fine until a user notices a section lands 40px off-screen after clicking a nav link.
Safari implements scroll-behavior: smooth on scroll containers and the behavior option on scrollIntoView. Check the Safari release notes for the row that matches your minimum-supported Safari version before you pick test fixtures, because specific behaviour has moved between releases:
block: 'start' and block: 'nearest' can resolve to different final positions.For deterministic assertions use behavior: 'instant' and a short settle delay before reading window.scrollY, so you measure the final scroll position rather than a frame in the middle of an animation.
The Playwright page.evaluate() API runs a function in the browser's page context and returns the function's serialisable result; if the function returns a Promise, Playwright awaits it. That is the right escape hatch for calling scrollIntoView and reading state that the locator API does not expose:
const scrollY: number = await page.evaluate((selector) => {
const el = document.querySelector(selector);
if (!el) throw new Error(`Selector not found: ${selector}`);
el.scrollIntoView({ behavior: 'instant', block: 'start' });
return window.scrollY;
}, '#features');
Snap and anchor logic can run after scrollIntoView returns, so the short settle delay in the full spec below is deliberate. If your own app needs longer, tune the number to the smallest value that stops flapping.
The first run writes one JSON file per browser. Commit these to source control so local and CI runs agree on the expected numbers:
scroll-baselines/
webkit-scroll-baseline.json
chromium-scroll-baseline.json
Each file maps CSS selector to measured scrollY:
{
"#hero": 0,
"#features": 820,
"#pricing": 1664,
"#contact": 2512
}
When you intentionally change layout, delete the stale baseline file, re-run, and review the diff in the PR before merging. Never hand-edit the numbers — they should always be the result of a recorded run.
Replace PROBE_SELECTORS with real, stable selectors from your own project and set TEST_URL to a staging or local origin. The spec fails fast if a selector is missing or if drift exceeds tolerance — a failed assertion cannot be mistaken for a passing run.
// tests/scroll-regression.spec.ts
import { test, expect } from '@playwright/test';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
const BASELINE_DIR = join(__dirname, 'scroll-baselines');
const TOLERANCE_PX = 5;
const SETTLE_MS = 150;
// Replace with real, stable selectors from your project
const PROBE_SELECTORS = ['#hero', '#features', '#pricing', '#contact'];
type Baseline = Record<string, number>;
function baselinePath(browserName: string): string {
mkdirSync(BASELINE_DIR, { recursive: true });
return join(BASELINE_DIR, `${browserName}-scroll-baseline.json`);
}
test.describe('scroll-behavior regression', () => {
test('record or assert scroll positions', async ({ page, browserName }, testInfo) => {
const url = process.env.TEST_URL;
if (!url) throw new Error('Set TEST_URL to your local or staging origin');
await page.goto(url, { waitUntil: 'domcontentloaded' });
const measured: Baseline = {};
const evidenceDir = testInfo.outputPath('scroll-evidence');
mkdirSync(evidenceDir, { recursive: true });
for (const selector of PROBE_SELECTORS) {
await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) throw new Error(`Element not found: ${sel}`);
el.scrollIntoView({ behavior: 'instant', block: 'start' });
}, selector);
// Snap or anchor logic can run after scrollIntoView returns
await page.waitForTimeout(SETTLE_MS);
measured[selector] = await page.evaluate(() => window.scrollY);
const slug = selector.replace(/[^a-z0-9]/gi, '_');
await page.screenshot({
path: join(evidenceDir, `${browserName}-${slug}.png`),
fullPage: false,
});
}
const bPath = baselinePath(browserName);
if (!existsSync(bPath)) {
writeFileSync(bPath, JSON.stringify(measured, null, 2));
testInfo.annotations.push({
type: 'baseline-recorded',
description: `${browserName} baseline written to ${bPath}`,
});
return; // skip assertions on the recording run
}
const baseline: Baseline = JSON.parse(readFileSync(bPath, 'utf8'));
for (const sel of PROBE_SELECTORS) {
const expected = baseline[sel];
const actual = measured[sel];
if (expected === undefined) continue;
const drift = Math.abs(actual - expected);
const label = `${browserName} scroll drift "${sel}": expected ${expected}px ±${TOLERANCE_PX}, got ${actual}px`;
if (browserName === 'webkit' && drift > TOLERANCE_PX) {
testInfo.annotations.push({ type: 'safari-scroll-drift', description: label });
}
expect(drift, label).toBeLessThanOrEqual(TOLERANCE_PX);
}
});
});
The safari-scroll-drift annotation lets a dashboard (Allure, Datadog CI Visibility, or any JSON-reporter consumer) filter Safari-only regressions separately from Chromium noise, while the expect still fails the run so the regression cannot be ignored.
A minimal Playwright config running WebKit and Chromium side by side:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
reporter: [['html'], ['json', { outputFile: 'results.json' }]],
});
Run npx playwright test --project webkit to record or check only the WebKit baseline when you are chasing a Safari-only failure.
Playwright's WebKit project is derived from WebKit main-branch sources rather than the branded Safari app, so a final pass against Safari on macOS is still worth doing before you commit a new baseline:
document.querySelector('#features').scrollIntoView({behavior:'instant',block:'start'}); window.scrollY for each selector and write the result next to the committed baseline value.behavior:'smooth' and confirm the final scrollY still lands on the same number, waiting at least 500 ms after the call before reading.webkit-scroll-baseline.json, re-running Playwright under the same OS and Safari version, and reviewing the resulting JSON diff. Keep the old file in Git history for bisecting.Quick worked line for a one-Mac nightly job over 12 months, entry price only (hardware specifics confirmed in each vendor's quote):
| Option | Monthly | 12-month total |
|---|---|---|
| Figment M-series entry, from $95/mo | $95.00 | $1,140.00 |
| CloudMac M4/16GB/256GB, $99.99/mo | $99.99 | $1,199.88 |
| CloudMac M4/16GB/256GB, $999/year prepay | $83.25 effective | $999.00 |
CloudMac's $999/year prepay is $141 cheaper over 12 months than 12 × $95. Figment's $95 entry is an unspecified M-series configuration, not a declared M4/16GB, so this is an entry-price comparison rather than equal hardware. Confirm the exact chip, RAM, disk, availability and any tax or fees in the quote before deciding. On 2026-11-10 the CloudMac 512GB and 1TB rows were listed as fully reserved; the 256GB row was still taking reservations.
Figment's differentiator at this task is the managed CI work on the $249 and $999 plans — recording the WebKit baseline, wiring Playwright into a scheduled job, keeping the Safari version pinned between runs — rather than a line on a hardware spec sheet.
Pair the spec with a scheduled GitHub Actions workflow targeting a self-hosted runner on the Figment Mac, and scroll regressions surface in your morning Slack notification rather than in a user bug report. Capture the scroll-evidence directory and results.json as workflow artifacts so the HTML report, screenshots and annotations are one click away from the failing run.
Explore Mac hosting options at /mac-hosting or start a configuration request at /mac-hosting/start.