Visual Testing for Internationalized Web Apps with a Screenshot API

2026-05-20 | Tags: [screenshot-api, i18n, l10n, internationalization, visual-testing, ci-cd]

Internationalization bugs have a specific failure mode: the English version passes all your tests, and the Arabic version silently breaks the layout because nobody tested it visually. RTL text flow doesn't just flip text — it flips navigation, sidebars, button placement, and icon alignment. A date that renders correctly as "March 19, 2026" in English may overflow its container as "19 de marzo de 2026" in Spanish. A price displayed as "$29.99" occupies different space than "€29,99" or "¥3,299".

Screenshot APIs are well-suited to i18n visual QA because the problem is inherently visual: you need to see what the page looks like, not just verify that translated strings are present. This post covers four patterns for automated internationalization screenshot testing.

1. Multi-Locale Capture Pipeline

The simplest i18n screenshot pattern: capture the same page across all supported locales, organized for comparison.

import requests
import json
from pathlib import Path
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed

API_KEY = "your_api_key"
SNAPSHOT_DIR = Path("/var/i18n-snapshots")

LOCALES = [
    {"code": "en", "url_prefix": "https://example.com/en", "dir": "ltr"},
    {"code": "fr", "url_prefix": "https://example.com/fr", "dir": "ltr"},
    {"code": "de", "url_prefix": "https://example.com/de", "dir": "ltr"},
    {"code": "es", "url_prefix": "https://example.com/es", "dir": "ltr"},
    {"code": "ja", "url_prefix": "https://example.com/ja", "dir": "ltr"},
    {"code": "ar", "url_prefix": "https://example.com/ar", "dir": "rtl"},  # RTL
    {"code": "he", "url_prefix": "https://example.com/he", "dir": "rtl"},  # RTL
    {"code": "zh-TW", "url_prefix": "https://example.com/zh-tw", "dir": "ltr"},
]

PAGES = [
    {"path": "/", "label": "home"},
    {"path": "/pricing", "label": "pricing"},
    {"path": "/checkout", "label": "checkout"},  # Most i18n breakage happens here
    {"path": "/profile", "label": "profile"},
]

def capture_locale_page(locale: dict, page: dict, run_id: str) -> dict:
    url = f"{locale['url_prefix']}{page['path']}"
    locale_dir = SNAPSHOT_DIR / run_id / locale["code"]
    locale_dir.mkdir(parents=True, exist_ok=True)

    # RTL locales need the same 1280px viewport —
    # the browser handles directionality via the page's HTML dir attribute
    resp = requests.post(
        "https://hermesforge.dev/api/screenshot",
        json={
            "url": url,
            "format": "png",
            "viewport_width": 1280,
            "full_page": True,
            "delay": 1500,
            "block_ads": True,
        },
        headers={"X-API-Key": API_KEY},
        timeout=60,
    )

    filename = f"{page['label']}.png"
    result = {
        "locale": locale["code"],
        "direction": locale["dir"],
        "page": page["label"],
        "url": url,
        "run_id": run_id,
        "captured_at": datetime.now(timezone.utc).isoformat(),
    }

    if resp.ok:
        (locale_dir / filename).write_bytes(resp.content)
        result["status"] = "ok"
        result["file_size_bytes"] = len(resp.content)
    else:
        result["status"] = "error"
        result["error"] = f"HTTP {resp.status_code}"

    return result


def run_i18n_snapshot(run_id: str = None) -> list[dict]:
    if run_id is None:
        run_id = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")

    tasks = [
        (locale, page)
        for locale in LOCALES
        for page in PAGES
    ]

    results = []
    with ThreadPoolExecutor(max_workers=4) as executor:
        futures = {
            executor.submit(capture_locale_page, locale, page, run_id): (locale, page)
            for locale, page in tasks
        }
        for future in as_completed(futures):
            result = future.result()
            results.append(result)
            status = result["status"]
            print(f"[{status.upper()}] {result['locale']} / {result['page']}")

    # Write run manifest
    manifest_path = SNAPSHOT_DIR / run_id / "manifest.json"
    manifest_path.write_text(json.dumps({
        "run_id": run_id,
        "locales": len(LOCALES),
        "pages": len(PAGES),
        "total_captures": len(results),
        "successful": sum(1 for r in results if r["status"] == "ok"),
        "results": results,
    }, indent=2))

    ok = sum(1 for r in results if r["status"] == "ok")
    print(f"\nRun {run_id}: {ok}/{len(results)} captures successful")
    return results


run_i18n_snapshot()

The output structure is snapshots/{run_id}/{locale_code}/{page_label}.png — making it easy to compare the same page across locales by navigating the directory or feeding into a visual diff tool.

2. RTL Layout Verification

RTL (right-to-left) layouts are the most common source of invisible i18n breakage. When a page sets dir="rtl" on the HTML element, every browser engine mirrors the layout: navigation moves from right to left, sidebars swap sides, and flex/grid containers reverse their flow direction. A page that looks correct in LTR can have completely broken navigation, overlapping elements, or cut-off text in RTL.

The capture approach is the same as any other locale — you don't need to do anything special in the API call, because the browser handles directionality based on the page's own HTML. What matters is capturing the full page and comparing it against your expected RTL design.

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const API_KEY = "your_api_key";
const BASELINE_DIR = "/var/i18n-baselines/rtl";
const CURRENT_DIR = "/var/i18n-current/rtl";

const RTL_PAGES = [
  { locale: "ar", url: "https://example.com/ar/", label: "home" },
  { locale: "ar", url: "https://example.com/ar/pricing", label: "pricing" },
  { locale: "he", url: "https://example.com/he/", label: "home" },
  { locale: "he", url: "https://example.com/he/pricing", label: "pricing" },
];

async function captureRTLPage(page) {
  const resp = await fetch("https://hermesforge.dev/api/screenshot", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": API_KEY },
    body: JSON.stringify({
      url: page.url,
      format: "png",
      viewport_width: 1280,
      full_page: true,
      delay: 1500,
    }),
  });

  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  return Buffer.from(await resp.arrayBuffer());
}

async function verifyRTLLayouts(isBaseline = false) {
  const outputDir = isBaseline ? BASELINE_DIR : CURRENT_DIR;
  const results = [];

  for (const page of RTL_PAGES) {
    try {
      const imageBytes = await captureRTLPage(page);
      const hash = crypto.createHash("sha256").update(imageBytes).digest("hex");

      const dir = path.join(outputDir, page.locale);
      fs.mkdirSync(dir, { recursive: true });
      const filepath = path.join(dir, `${page.label}.png`);
      fs.writeFileSync(filepath, imageBytes);

      if (!isBaseline) {
        // Compare against baseline
        const baselinePath = path.join(BASELINE_DIR, page.locale, `${page.label}.png`);
        if (fs.existsSync(baselinePath)) {
          const baselineBytes = fs.readFileSync(baselinePath);
          const baselineHash = crypto
            .createHash("sha256")
            .update(baselineBytes)
            .digest("hex");
          const changed = hash !== baselineHash;

          results.push({
            locale: page.locale,
            page: page.label,
            changed,
            current_hash: hash.slice(0, 12),
            baseline_hash: baselineHash.slice(0, 12),
          });

          console.log(`[${changed ? "CHANGED" : "OK"}] ${page.locale}/${page.label}`);
        }
      } else {
        console.log(`[BASELINE] ${page.locale}/${page.label} saved`);
      }
    } catch (err) {
      console.error(`[ERROR] ${page.locale}/${page.label}: ${err.message}`);
    }

    await new Promise((r) => setTimeout(r, 600));
  }

  return results;
}

// First run: establish baseline
// verifyRTLLayouts(true);

// Subsequent runs: compare against baseline
verifyRTLLayouts(false).then((results) => {
  const changed = results.filter((r) => r.changed);
  if (changed.length > 0) {
    console.log(`\n${changed.length} RTL layout(s) changed — review required`);
    process.exit(1); // Non-zero exit for CI
  }
});

Why hash comparison works for RTL testing: RTL layout bugs often produce visually obvious differences — navigation on the wrong side, text cut off at the wrong edge. A pixel-level hash change between runs is a reliable signal that something visually significant changed. For pixel-perfect comparison you'd use a dedicated visual diff tool like pixelmatch, but hash change detection is a fast first-pass filter.

3. Content Overflow and Truncation Detection

Long translations break fixed-width containers. A German compound noun can be 3x longer than its English equivalent. Japanese text is compact but requires line-height adjustments that can push content below the fold. Spanish strings regularly overflow English-sized button labels.

You can detect overflow automatically by injecting JavaScript that scans for elements where scrollWidth > clientWidth — but screenshot comparison is the human-readable version of the same check.

import requests
import json
from pathlib import Path
from datetime import datetime, timezone

API_KEY = "your_api_key"
OVERFLOW_DIR = Path("/var/i18n-overflow-checks")

# Problematic locales + pages to check for content overflow
OVERFLOW_CHECKS = [
    # German: long compound words overflow button labels and nav items
    {"locale": "de", "url": "https://example.com/de/pricing", "concern": "button_labels"},
    {"locale": "de", "url": "https://example.com/de/checkout", "concern": "form_labels"},
    # Spanish: strings ~20% longer than English
    {"locale": "es", "url": "https://example.com/es/pricing", "concern": "button_labels"},
    # Japanese: compact text but different line-height requirements
    {"locale": "ja", "url": "https://example.com/ja/checkout", "concern": "form_layout"},
    # Arabic: RTL + Arabic numerals in prices
    {"locale": "ar", "url": "https://example.com/ar/pricing", "concern": "price_display"},
]

def capture_with_overflow_js(url: str) -> bytes:
    """
    Capture page with JavaScript that highlights overflowing elements in red.
    Makes overflow visually obvious in the screenshot.
    """
    highlight_js = """
    (function() {
        document.querySelectorAll('*').forEach(function(el) {
            if (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight) {
                el.style.outline = '3px solid red';
                el.style.outlineOffset = '-3px';
            }
        });
    })();
    """

    resp = requests.post(
        "https://hermesforge.dev/api/screenshot",
        json={
            "url": url,
            "format": "png",
            "viewport_width": 1280,
            "full_page": True,
            "delay": 1500,
            "js": highlight_js,  # Inject overflow-detection JS before capture
        },
        headers={"X-API-Key": API_KEY},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.content


def check_locale_overflow(check: dict) -> dict:
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
    save_dir = OVERFLOW_DIR / check["locale"]
    save_dir.mkdir(parents=True, exist_ok=True)

    try:
        image_bytes = capture_with_overflow_js(check["url"])
        filename = f"{check['concern']}_{timestamp}.png"
        (save_dir / filename).write_bytes(image_bytes)

        return {
            "locale": check["locale"],
            "url": check["url"],
            "concern": check["concern"],
            "status": "captured",
            "filename": filename,
            "note": "Red outlines indicate overflowing elements",
        }
    except Exception as e:
        return {
            "locale": check["locale"],
            "concern": check["concern"],
            "status": "error",
            "error": str(e),
        }


results = [check_locale_overflow(c) for c in OVERFLOW_CHECKS]
print(json.dumps(results, indent=2))

The JavaScript injection technique: By injecting JS that outlines overflowing elements in red before the screenshot is taken, overflow issues become immediately visible in the image without needing pixel-diff tooling. A reviewer looking at the screenshot can spot red outlines instantly. This turns a programmatic check into a visual artifact that non-developers can review.

4. CI/CD Locale Screenshot Testing

For teams running CI on every commit, locale screenshots can be captured as part of the PR validation pipeline and attached as artifacts for reviewer inspection.

# .github/workflows/i18n-visual-test.yml
name: i18n Visual Test

on:
  pull_request:
    paths:
      - 'src/**'
      - 'locales/**'
      - 'translations/**'

jobs:
  locale-screenshots:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy preview
        id: deploy
        run: echo "preview_url=https://preview-${{ github.sha }}.example.com" >> $GITHUB_OUTPUT

      - name: Capture locale screenshots
        env:
          SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }}
          PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
        run: |
          python3 scripts/capture_locales.py \
            --base-url "$PREVIEW_URL" \
            --locales en,fr,de,es,ar,he \
            --pages /,/pricing,/checkout \
            --output-dir locale-screenshots/

      - name: Upload locale screenshots
        uses: actions/upload-artifact@v4
        with:
          name: locale-screenshots-${{ github.sha }}
          path: locale-screenshots/
          retention-days: 14

      - name: Check for RTL regressions
        run: |
          python3 scripts/compare_rtl_baselines.py \
            --current locale-screenshots/ \
            --baseline .ci/rtl-baselines/ \
            --fail-on-change
# scripts/capture_locales.py (simplified)
import argparse
import requests
import os
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--base-url", required=True)
    parser.add_argument("--locales", required=True)  # Comma-separated
    parser.add_argument("--pages", required=True)    # Comma-separated paths
    parser.add_argument("--output-dir", required=True)
    args = parser.parse_args()

    api_key = os.environ["SCREENSHOT_API_KEY"]
    locales = args.locales.split(",")
    pages = args.pages.split(",")
    output_dir = Path(args.output_dir)

    tasks = [(locale, page) for locale in locales for page in pages]

    def capture(task):
        locale, page = task
        url = f"{args.base_url}/{locale}{page}"
        resp = requests.post(
            "https://hermesforge.dev/api/screenshot",
            json={"url": url, "format": "png", "full_page": True, "delay": 1500},
            headers={"X-API-Key": api_key},
            timeout=60,
        )
        out_path = output_dir / locale / f"{page.strip('/') or 'home'}.png"
        out_path.parent.mkdir(parents=True, exist_ok=True)
        if resp.ok:
            out_path.write_bytes(resp.content)
            return f"[OK] {locale}{page}"
        return f"[FAIL] {locale}{page}: HTTP {resp.status_code}"

    with ThreadPoolExecutor(max_workers=4) as ex:
        for result in as_completed([ex.submit(capture, t) for t in tasks]):
            print(result.result())

if __name__ == "__main__":
    main()

With this setup, every PR that touches source code or translation files captures locale screenshots and attaches them as CI artifacts. Reviewers can download the artifact and visually inspect layouts before merging — no local browser setup required.


Quick Reference: i18n Screenshot Patterns

Locale Type Special Considerations Viewport
LTR (Latin) String length overflow, font availability 1280px
RTL (Arabic, Hebrew) Full layout mirror, icon flip, nav swap 1280px
CJK (Chinese, Japanese, Korean) Dense text, different line-height, mixed scripts 1280px
Bidirectional (mixed RTL/LTR) Number formatting, inline code in RTL context 1280px

Note on Accept-Language headers: Some applications serve locale-specific content via HTTP Accept-Language headers rather than URL prefixes. The screenshot API does not support setting custom HTTP headers on the target request — use URL-based locale switching (/en/, /fr/, ?lang=ar) for automated capture pipelines.


All code examples use hermesforge.dev. Authentication requires a free API key.