[{"data":1,"prerenderedAt":20},["ShallowReactive",2],{"blog:post:en:visual-regression-testing-across-regions":3},{"slug":4,"lang":5,"title":6,"summary":7,"date":8,"tags":9,"tag_slugs":15,"thumbnail_url":16,"translations":17,"body":18,"asset_base":19},"visual-regression-testing-across-regions","en","Visual Regression Testing Across Regions","Run automated visual regression tests from multiple geolocations using rotating residential proxies and headless browsers to catch global UI inconsistencies.","2026-10-19",[10,11,12,13,14],"visual-regression","proxies","playwright","ci-cd","geotargeting",[10,11,12,13,14],"https://blog-api.ro-proxy.com/api/blog/posts/visual-regression-testing-across-regions/thumbnail.svg?lang=en",[5],"## Why cross‑region visual checks matter\n\nModern web applications serve users from dozens of countries. CDN edge nodes, localized assets, A/B experiments, and region‑specific feature flags can all change the rendered UI. A screenshot taken from a single static IP rarely reveals layout shifts caused by:\n\n* Different font‑fallback stacks served from regional CDNs\n* Locale‑dependent text length (e.g., German vs. Japanese)\n* Geo‑targeted advertising or consent banners\n* Edge‑side CSS/JS bundles that differ per POP\n\nRunning visual regression only from your CI server's static IP gives a false sense of safety. Rotating residential proxies let you **exercise the exact network path a real user follows**, capturing the final painted pixels after all edge logic has run.\n\n## Architecture overview\n\nA scheduler picks a target country, hands a fresh residential IP to a headless browser, captures a full‑page screenshot, and diffs it against a versioned baseline stored in object storage.\n\n```mermaid\nflowchart LR\n    CI[CI Pipeline] -->|trigger| Scheduler\n    Scheduler -->|pick region| ProxyPool[(Rotating Residential Proxy Pool)]\n    ProxyPool -->|assign IP| Browser[Headless Browser (Playwright)]\n    Browser -->|navigate + screenshot| Storage[(Artifact Store)]\n    Storage -->|compare| DiffTool[Pixelmatch]\n    DiffTool -->|report| CI\n```\n\nThe key design choices:\n\n1. **Sticky sessions** – request one IP per region per test run so you pay for reuse rather than churn.\n2. **Network idle waits** – ensure third‑party scripts finish before snapshotting.\n3. **Dynamic content suppression** – hide timestamps, A/B badges, and consent dialogs so they don't corrupt baselines.\n4. **Versioned baselines** – store each baseline under `baselines/\u003Cgit-sha>/` so history is auditable.\n\n## Choosing the right proxy type\n\n| Proxy type | Pros | Cons | Best fit |\n|------------|------|------|----------|\n| **Rotating residential** | Real ISP ASN, high trust, low block rate | Higher cost, variable latency | ✅ Primary – mimics genuine user traffic |\n| **Static ISP** | Consistent IP, lower latency | Limited geo diversity | ✅ Good for stable baseline runs in one region |\n| **Datacenter** | Cheap, fast | Frequently blocked, obvious ASN | ❌ Not recommended |\n| **Mobile** | Carrier‑grade IPs | Expensive, limited pool | ✅ Optional for mobile viewports |\n\n**Recommendation:** Build a *tiered pool* — a small set of static ISP proxies for stable baseline captures, plus a larger rotating residential pool for daily multi‑region sweeps.\n\n## Implementation with Playwright (Node.js)\n\n### 1. Install dependencies\n\n```bash\nnpm i -D playwright @scrap-ice/proxy-rotator pixelmatch pngjs\n```\n\n`@scrap-ice/proxy-rotator` is an open‑source library that fetches rotating residential proxy URLs by country, which keeps this post free of vendor‑specific SDK references.\n\n### 2. Proxy helper module\n\n```js\n// lib/proxy.js\nconst proxyRotator = require('@scrap-ice/proxy-rotator');\n\n/**\n * Returns a proxy URL for the given country code.\n * Sticky mode keeps the same IP for the whole test run.\n */\nasync function getProxy(country) {\n  const url = await proxyRotator.get({\n    type: 'residential',\n    country,\n    sticky: true,\n    ttlSeconds: 300,\n  });\n  return url;\n}\n\nmodule.exports = { getProxy };\n```\n\n### 3. Screenshot capture with dynamic‑content suppression\n\n```js\n// tests/visual.spec.js\nconst { test, expect } = require('@playwright/test');\nconst fs = require('fs');\nconst path = require('path');\nconst { PNG } = require('pngjs');\nconst pixelmatch = require('pixelmatch');\nconst { getProxy } = require('../lib/proxy');\n\nconst REGIONS = ['us', 'de', 'jp', 'br', 'au'];\nconst BASELINE_DIR = path.resolve(__dirname, '../baselines');\nconst ARTIFACT_NL = path.resolve(__dirname, '../artifacts');\n\nasync function compareImages(baselinePath, currentPath) {\n  const [baseImg, curImg] = await Promise.all([\n    PNG.sync.read(fs.readFileSync(baselinePath)),\n    PNG.sync.read(fs.readFileSync(currentPath)),\n  ]);\n  const diff = new PNG({ width: baseImg.width, height: baseImg.height });\n  const mismatched = pixelmatch(\n    baseImg.data,\n    curImg.data,\n    diff.data,\n    baseImg.width,\n    baseImg.height,\n    { threshold: 0.01, includeAA: true }\n  );\n  return mismatched;\n}\n\ntest.describe('Visual Regression', () => {\n  for (const region of REGIONS) {\n    test(`visual regression – ${region.toUpperCase()}`, async ({ page }) => {\n      const proxyUrl = await getProxy(region);\n      await page.setProxy({ server: proxyUrl });\n\n      // Suppress flaky dynamic elements\n      await page.addStyleTag({\n        content: `\n          [data-testid=\"dynamic-timestamp\"] { visibility: hidden; }\n          .ab-variant-badge { display: none; }\n        `,\n      });\n\n      await page.goto('https://example.com/pricing', {\n        waitUntil: 'networkidle',\n        timeout: 60_000,\n      });\n\n      await page.waitForFunction(() => document.fonts.ready.then(() => true));\n\n      const currentPath = path.join(ARTIFACT_NL, `pricing-${region}.png`);\n      const baselinePath = path.join(BASELINE_DIR, `pricing-${region}.png`);\n\n      await page.screenshot({ path: currentPath, fullPage: true });\n\n      if (!fs.existsSync(baselinePath)) {\n        fs.copyFileSync(currentPath, baselinePath);\n        console.log(`Baseline created for ${region}`);\n        return;\n      }\n\n      const diffPixels = await compareImages(baselinePath, currentPath);\n      expect(diffPixels).toBeLessThanOrEqual(200);\n    });\n  }\n});\n```\n\n### 4. CI integration with GitHub Actions\n\n```yaml\n# .github/workflows/visual-regression.yml\nname: Visual Regression\non:\n  push:\n    branches: [main]\n  schedule:\n    - cron: '0 3 * * *'\njobs:\n  visual:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n      - run: npm ci\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test tests/visual.spec.js\n      - uses: actions/upload-artifact@v4\n        if: always()\n        with:\n          name: artifacts-${{ github.run_id }}\n          path: artifacts/**\n          retention-days: 7\n```\n\n## Handling flaky dynamic content\n\n| Source | Mitigation |\n|--------|------------|\n| **Timestamp / date widgets** | Hide via CSS (`visibility: hidden`) or mock the clock with `page.clock.install({ now: new Date('2024-01-01') })`. |\n| **A/B test banners** | Add a test‑only query param (`?ab=control`) or send a feature‑flag header (`x-ab-variant: control`). |\n| **Third‑party ads / consent dialogs** | Block request domains via `page.route('**/*.doubleclick.net/**', route => route.abort())`. |\n| **Font loading races** | `await page.waitForLoadState('networkidle')` + `await page.waitForFunction(() => document.fonts.ready.then(() => true))`. |\n| **Lazy‑loaded images** | Scroll to bottom before screenshot: `await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))`. |\n\n## Baseline promotion workflow\n\n1. Versioned baselines stored under `baselines/\u003Cgit-sha>/`.\n2. When a PR passes on all regions, a GitHub Action copies the new screenshots to the `main` baseline folder.\n3. For large diffs, surface a side‑by‑side view in the PR using the pixelmatch output rendered as HTML.\n\n## Cost‑control tips\n\n* **Session reuse** – use sticky sessions so you pay for one IP per region instead of one per page.\n* **Parallelism limit** – run at most 3 concurrent regions to stay within typical residential‑proxy bandwidth caps.\n* **Cache baselines** – they never change unless you intentionally promote them, so no extra proxy traffic.\n* **Monitor success rates** – pause regions whose success rate dips below 95% and investigate the cause before rerunning.\n\n## Extending to mobile viewports\n\n```js\nconst DEVICES = [\n  { name: 'desktop', viewport: { width: 1366, height: 768 } },\n  { name: 'mobile', viewport: { width: 390, height: 844 }, userAgent: 'iPhone Safari' },\n];\n\nfor (const region of REGIONS) {\n  for (const device of DEVICES) {\n    test(`visual – ${region}/${device.name}`, async ({ page }) => {\n      const proxyUrl = await getProxy(region);\n      await page.setProxy({ server: proxyUrl });\n      await page.setViewportSize(device.viewport);\n      if (device.userAgent) await page.setUserAgent(device.userAgent);\n      // … same capture/compare logic …\n    });\n  }\n}\n```\n\n## Troubleshooting checklist\n\n1. **Proxy auth failures (407)** – verify `username:password` encoding; special characters must be URL‑encoded.\n2. **IP not matching target country** – confirm ASN and geo before the run, then rerun.\n3. **Screenshot timeout** – increase `navigationTimeout` and ensure `waitUntil: 'networkidle'` isn't stuck on a long‑polling endpoint; abort those routes explicitly.\n4. **Diff noise above threshold** – run the test locally with `headless: false` to visually inspect the captured page.\n5. **Rate‑limited by target site** – swap to a fresh proxy when you receive 429/5xx responses.\n\n## TL;DR\n\nYou walk away with a repeatable, CI‑ready pipeline that captures full‑page screenshots from real residential IPs in any country. The setup reuses sticky sessions, suppresses flaky UI elements, and produces pixel‑level diffs against versioned baselines. Start small — two regions, one critical page, nightly schedule — then expand the matrix and add mobile viewports once the baseline is stable.\n","https://blog-api.ro-proxy.com/api/blog/posts/visual-regression-testing-across-regions/assets",1792821467519]