Chapter 16Lesson 02~240 minutes

Cross-Browser, Responsive, Localization, and Compatibility Testing: Guided Hands-On Workflow

Now you will execute a small compatibility matrix rather than merely describe one. The lab discovers two locally available browsers, prefers Gecko-plus-Chromium diversity when possible, runs two high-value viewport/locale cases per browser, records capabilities and runtime locale/timezone, and proves the same business outcome without demanding identical pixels.

Two-browser labResponsive fixtureLocalizationCapabilitiesScreenshots

Learning objectives

  • Create a disposable responsive/localized loopback fixture.
  • Discover two locally available Selenium browsers and record preflight provenance.
  • Run a bounded browser × scenario matrix without creating the full Cartesian product.
  • Use stable data-testid selectors across translated UI text.
  • Capture capabilities, locale/timezone, screenshots, and normalized business outcomes.

1. Create the disposable lab

The following example makes the Create the disposable lab behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

mkdir selenium-ch16-workflow
cd selenium-ch16-workflow
python -m venv .venv
# PowerShell: .\.venv\Scripts\Activate.ps1
# POSIX: source .venv/bin/activate
python -m pip install "selenium==4.47.0"
mkdir site evidence

Preflight requires Python 3.10+, Selenium 4.47.0, and at least two locally installed supported browsers among Firefox, Chrome, and Edge. Selenium Manager performs normal driver resolution. If you have only one browser, stop at preflight and install a second supported browser rather than pretending one run represents multiple browsers.

2. Build the responsive/localized fixture

The following example makes the Build the responsive/localized fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Compatibility Matrix Lab</title>
  <style>
    :root { font-family: system-ui, sans-serif; color-scheme: light dark; }
    body { margin: 0; background: Canvas; color: CanvasText; }
    .shell { max-width: 980px; margin: 0 auto; padding: 24px; }
    .toolbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
    .nav { display: flex; gap: 12px; }
    .card { border: 1px solid color-mix(in srgb, CanvasText 22%, transparent); border-radius: 14px; padding: 20px; margin-top: 20px; }
    .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
    .fact { border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); border-radius: 10px; padding: 12px; }
    button { min-height: 42px; padding: 0 16px; }
    @media (max-width: 699px) {
      .shell { padding: 14px; }
      .toolbar { align-items: stretch; flex-direction: column; }
      .nav { display: none; }
      .facts { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <main class="shell">
    <header class="toolbar">
      <div>
        <small data-testid="locale-label"></small>
        <h1 data-testid="heading"></h1>
      </div>
      <nav class="nav" aria-label="Demo navigation">
        <a href="#product" data-testid="nav-product"></a>
        <a href="#details" data-testid="nav-details"></a>
      </nav>
    </header>

    <section class="card" id="product" data-testid="product-card">
      <h2 data-testid="product-title"></h2>
      <p data-testid="product-description"></p>
      <button data-testid="primary-action" type="button"></button>
      <p data-testid="result" data-state="idle"></p>
    </section>

    <section class="card facts" id="details">
      <div class="fact"><strong data-testid="price-label"></strong><div data-testid="price"></div></div>
      <div class="fact"><strong data-testid="date-label"></strong><div data-testid="date" data-iso="2026-08-28T00:00:00Z"></div></div>
      <div class="fact"><strong>Layout</strong><div data-testid="layout-state" data-layout=""></div></div>
      <div class="fact"><strong>App locale</strong><div data-testid="locale-state" data-locale=""></div></div>
      <div class="fact"><strong>Browser language</strong><div data-testid="browser-language"></div></div>
      <div class="fact"><strong>Browser timezone</strong><div data-testid="browser-timezone"></div></div>
    </section>
  </main>
<script>
const copy = {
  en: {
    localeLabel: 'English demo', heading: 'Compatibility lab', product: 'Product', details: 'Details',
    title: 'Backup plan', description: 'A synthetic plan used only for browser automation training.',
    action: 'Start trial', idle: 'No action yet', accepted: 'Trial started', priceLabel: 'Price', dateLabel: 'Renewal date'
  },
  de: {
    localeLabel: 'Deutsche Demo', heading: 'Kompatibilitätslabor', product: 'Produkt', details: 'Details',
    title: 'Sicherungsplan', description: 'Ein synthetischer Tarif nur für Browser-Automatisierungstraining.',
    action: 'Testphase starten', idle: 'Noch keine Aktion', accepted: 'Testphase gestartet', priceLabel: 'Preis', dateLabel: 'Verlängerungsdatum'
  }
};
const params = new URLSearchParams(location.search);
const locale = copy[params.get('locale')] ? params.get('locale') : 'en';
const t = copy[locale];
const q = (id) => document.querySelector(`[data-testid="${id}"]`);
q('locale-label').textContent = t.localeLabel;
q('heading').textContent = t.heading;
q('nav-product').textContent = t.product;
q('nav-details').textContent = t.details;
q('product-title').textContent = t.title;
q('product-description').textContent = t.description;
q('primary-action').textContent = t.action;
q('result').textContent = t.idle;
q('price-label').textContent = t.priceLabel;
q('date-label').textContent = t.dateLabel;
q('price').textContent = new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(29.5);
q('date').textContent = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(q('date').dataset.iso));
q('locale-state').dataset.locale = locale;
q('locale-state').textContent = locale;
q('browser-language').textContent = navigator.language;
q('browser-timezone').textContent = Intl.DateTimeFormat().resolvedOptions().timeZone;
function updateLayout() {
  const state = innerWidth < 700 ? 'compact' : 'wide';
  q('layout-state').dataset.layout = state;
  q('layout-state').textContent = state;
}
addEventListener('resize', updateLayout);
updateLayout();
q('primary-action').addEventListener('click', () => {
  q('result').dataset.state = 'accepted';
  q('result').textContent = t.accepted;
});
</script>
</body>
</html>

The fixture exposes two kinds of state. Human-facing strings change between English and German. Stable data-testid identity and machine-readable data-state/data-layout attributes remain constant. That separation is the core compatibility contract.

3. Serve only on loopback

The following example makes the Serve only on loopback behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from functools import partial

HOST = "127.0.0.1"
PORT = 8780
handler = partial(SimpleHTTPRequestHandler, directory="site")
server = ThreadingHTTPServer((HOST, PORT), handler)
print(f"Compatibility fixture: http://{HOST}:{PORT}/")
try:
    server.serve_forever()
except KeyboardInterrupt:
    pass
finally:
    server.server_close()

The following example makes the Serve only on loopback behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python serve.py
# Leave this terminal open while the test runs.
# Expected: Compatibility fixture: http://127.0.0.1:8780/
Safety scope

The AUT is synthetic and bound to 127.0.0.1. It has no accounts, payments, production data, external network calls, or authentication state.

4. matrix_run.py — discover real browsers, then run four purposeful rows

The following example makes the matrix_run.py — discover real browsers, then run four purposeful rows behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from pathlib import Path
from urllib.parse import urlencode
import json
import hashlib
import selenium
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

BASE_URL = "http://127.0.0.1:8780/"
EVIDENCE = Path("evidence")
EVIDENCE.mkdir(exist_ok=True)

BUILDERS = {
    "firefox": webdriver.Firefox,
    "chrome": webdriver.Chrome,
    "edge": webdriver.Edge,
}
ENGINE = {"firefox": "gecko", "chrome": "chromium", "edge": "chromium"}
CASES = [
    {"name": "wide-en", "width": 1280, "height": 800, "locale": "en", "layout": "wide"},
    {"name": "compact-de", "width": 560, "height": 760, "locale": "de", "layout": "compact"},
]

def new_driver(name):
    return BUILDERS[name]()

def discover_browsers():
    found = []
    failures = {}
    for name in BUILDERS:
        driver = None
        try:
            driver = new_driver(name)
            caps = driver.capabilities
            found.append({
                "name": name,
                "engine": ENGINE[name],
                "browserName": caps.get("browserName"),
                "browserVersion": caps.get("browserVersion"),
                "platformName": caps.get("platformName"),
            })
        except WebDriverException as exc:
            failures[name] = type(exc).__name__
        finally:
            if driver:
                driver.quit()
    (EVIDENCE / "preflight.json").write_text(
        json.dumps({"selenium": selenium.__version__, "available": found, "unavailable": failures}, indent=2),
        encoding="utf-8",
    )
    if len(found) < 2:
        raise SystemExit("This lab requires two locally available browsers. Install a second supported browser, then rerun preflight.")
    # Prefer engine diversity when possible: Firefox + one Chromium browser.
    firefox = next((x for x in found if x["engine"] == "gecko"), None)
    chromium = next((x for x in found if x["engine"] == "chromium"), None)
    chosen = [firefox, chromium] if firefox and chromium else found[:2]
    return [x for x in chosen if x]

def run_case(browser, case):
    driver = new_driver(browser["name"])
    try:
        driver.set_window_size(case["width"], case["height"])
        driver.get(BASE_URL + "?" + urlencode({"locale": case["locale"]}))
        wait = WebDriverWait(driver, 4)
        layout = driver.find_element(By.CSS_SELECTOR, '[data-testid="layout-state"]')
        wait.until(lambda d: layout.get_attribute("data-layout") == case["layout"])
        locale = driver.find_element(By.CSS_SELECTOR, '[data-testid="locale-state"]')
        assert locale.get_attribute("data-locale") == case["locale"]

        # Stable selector: translated button text is evidence, not the locator contract.
        action = driver.find_element(By.CSS_SELECTOR, '[data-testid="primary-action"]')
        translated_action = action.text
        action.click()
        result = driver.find_element(By.CSS_SELECTOR, '[data-testid="result"]')
        assert result.get_attribute("data-state") == "accepted"

        shot = EVIDENCE / f'{browser["name"]}-{case["name"]}.png'
        driver.save_screenshot(str(shot))
        shot_hash = hashlib.sha256(shot.read_bytes()).hexdigest()
        caps = driver.capabilities
        return {
            "browser_requested": browser["name"],
            "engine_family": browser["engine"],
            "browserName": caps.get("browserName"),
            "browserVersion": caps.get("browserVersion"),
            "platformName": caps.get("platformName"),
            "viewport_requested": [case["width"], case["height"]],
            "window_reported": driver.get_window_size(),
            "app_locale": case["locale"],
            "translated_action": translated_action,
            "business_state": result.get_attribute("data-state"),
            "browser_language": driver.execute_script("return navigator.language"),
            "browser_timezone": driver.execute_script("return Intl.DateTimeFormat().resolvedOptions().timeZone"),
            "screenshot": str(shot),
            "screenshot_sha256": shot_hash,
        }
    finally:
        driver.quit()

def main():
    browsers = discover_browsers()
    rows = []
    for browser in browsers:
        for case in CASES:
            rows.append(run_case(browser, case))
    (EVIDENCE / "matrix.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
    assert all(row["business_state"] == "accepted" for row in rows)
    print(json.dumps(rows, indent=2))

if __name__ == "__main__":
    main()

The fast matrix is intentionally not 2 browsers × 2 viewports × 2 locales = 8 rows. It selects two scenario rows—wide/English and compact/German—and applies each to the two chosen browsers, producing four observations. That is enough to exercise browser diversity, responsive state, and localization while keeping feedback bounded.

5. What each operation reads or changes

The following table organizes the key choices and evidence for What each operation reads or changes. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Operation State read/changed Evidence
Create browser session Browser process/profile + WebDriver session Returned capabilities, session identity
Set window size Top-level browser window / responsive viewport Requested dimensions + get_window_size()
Navigate with ?locale=... Document + AUT application locale URL, data-locale, translated copy
Wait for layout state DOM state updated by resize handler data-layout=wide|compact
Click stable test hook Browser events + AUT result state data-state=accepted
Read runtime language/timezone Browser environment only navigator.language, Intl timezone
Save screenshot Evidence filesystem Per-row PNG + SHA-256
Quit Session/browser process No reusable session state should remain

6. Execute and inspect causality

The following example makes the Execute and inspect causality behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python matrix_run.py

# Inspect the evidence packet.
python -m json.tool evidence/preflight.json
python -m json.tool evidence/matrix.json
# PowerShell: Get-ChildItem evidence
# POSIX: ls -lh evidence

Expected shape: two available browsers are selected; each runs wide-en and compact-de; all four rows report business_state: accepted. The translated button text should differ by locale. Browser name/version, runtime language/timezone, window dimensions, and screenshot hashes may differ by environment.

7. Compare behavior, not pixel identity

Open the screenshots side by side. Different font rasterization, control styling, scrollbars, line breaks, antialiasing, or focus outlines can legitimately produce different bytes. A screenshot is evidence. It becomes a regression oracle only when you define a visual-testing product contract and tolerated variance.

This chapter therefore asserts semantic state—accepted, expected locale, expected responsive mode—and preserves pixels for diagnosis.

8. Why translated text is evidence, not identity

The following example makes the Why translated text is evidence, not identity behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# Brittle across locales:
# driver.find_element(By.XPATH, "//button[normalize-space()='Start trial']")

# Stable automation contract:
action = driver.find_element(By.CSS_SELECTOR, '[data-testid="primary-action"]')
translated_copy = action.text        # evidence / copy assertion if required
action.click()                       # interaction does not depend on translation

If the product requirement is specifically “German copy equals the approved translation,” then assert that copy from a controlled translation fixture. Do not make every ordinary interaction depend on translated text.

9. Challenge: add risk coverage without exploding the matrix

Your product is adding an RTL locale and a narrow-table regression was previously found only in Firefox. Which single new row adds more value: (A) Chrome-wide-RTL, (B) Firefox-compact-RTL, or (C) every browser × every viewport × RTL? Justify the choice from risk and feedback cost, then add only the selected row to CASES.

10. Cleanup

The following example makes the Cleanup behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# Stop serve.py with Ctrl+C first.
cd ..
# Preserve synthetic evidence only if you want it for review.
# Then remove the disposable directory.
# PowerShell: Remove-Item -Recurse -Force selenium-ch16-workflow
# POSIX: rm -rf selenium-ch16-workflow

11. Summary and bridge

You now have a real two-browser, responsive, localized matrix with environment provenance and browser-specific screenshots. Lesson 3 decides which rows belong in per-change CI, scheduled suites, local Grid, real-device lanes, or optional hosted infrastructure.

Knowledge check

Why does the lab stop when fewer than two browsers are available?

Why are there four fast rows rather than eight?

Why can screenshot hashes differ when the test passes?

Why is data-testid used for the translated button?

What state does navigator.language describe?

Next lesson

Cross-Browser, Responsive, Localization, and Compatibility Testing: Configuration, Design Patterns, and Trade-Offs

Continue with Cross-Browser, Responsive, Localization, and Compatibility Testing: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against Selenium and Apple primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0 and Python 3.10+, use Selenium Manager for normal local driver resolution, require at least two actually available local desktop browsers, and use only a loopback synthetic AUT. Browser locale/timezone are recorded as environment evidence; application locale is controlled through the fixture. Desktop set_window_size() is labeled responsive desktop coverage, not real-device/mobile-engine emulation. Safari coverage is treated as an Apple-platform lane, not simulated by Chromium. Hosted browser clouds, real-device services, and enterprise browser farms are optional architecture only.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.