Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Safari Scroll-Behavior JavaScript Regression Tests

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 scroll-behavior quirks worth asserting on

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:

  • Mid-animation layout change. A smooth scroll can end short of the target when content is inserted above the scroll container during the animation (lazy images, injected banners, deferred ads).
  • Scroll snap re-snap. Safari releases have adjusted how snap targets are chosen when multiple snap areas are aligned; watch the WebKit blog for the release that shipped on your current test target and pin that Safari version in your test matrix.
  • Device-pixel rounding. Fractional pixel rounding can produce 1–2px differences between WebKit and Chromium for the same layout, which is why the tolerance in this spec is ±5px rather than an exact match.
  • Oversized targets. For elements taller than the viewport, 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.

Reading scroll state with page.evaluate()

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.

Recording the baseline

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.

The full test: scroll-regression.spec.ts

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.

Manual acceptance worksheet for a Safari-only regression

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:

  1. Record the Safari version and macOS build on the machine you use (Safari → About Safari; Apple menu → About This Mac). Keep both in the PR description.
  2. Open the target URL in Safari and manually scroll to each probed element using the in-page navigation. Note any visible mis-landing, clipped headings or snap jumps.
  3. In Web Inspector, run document.querySelector('#features').scrollIntoView({behavior:'instant',block:'start'}); window.scrollY for each selector and write the result next to the committed baseline value.
  4. Repeat with behavior:'smooth' and confirm the final scrollY still lands on the same number, waiting at least 500 ms after the call before reading.
  5. Save a Safari screenshot of the viewport at each probe point and attach it next to the automated Playwright screenshots in the PR.
  6. If any probe drifts more than ±5px, decide whether it is a real regression or a legitimate layout change that justifies a baseline update.
  7. Only update the baseline by deleting 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.

Which Figment Mac to pick, with a worked cost line

  • $29 virtual Intel macOS — enough for scroll-position regression checks against Intel Safari on the installed macOS release. Access is through the browser console; agents run from the desktop terminal and can reach your CI service outbound. VM capacity is being verified, so confirm availability before you plan around it.
  • M-series Mac, from $95/month — choose this when you need the current Apple-silicon build of Safari, more RAM, or parallel WebKit jobs. Exact chip, RAM and disk are set in the quote.
  • Managed Build Mac, $249/month — one M-series Mac with CI setup, backups, monitoring and 30 minutes of monthly support included. Useful if you want the Playwright-on-WebKit job wired up and the Safari version pinned between runs without owning the pipeline yourself.

Quick worked line for a one-Mac nightly job over 12 months, entry price only (hardware specifics confirmed in each vendor's quote):

OptionMonthly12-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.

Wiring it into a nightly job

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.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms