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.
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-testidselectors 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/
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?
Because a single browser cannot provide cross-browser evidence. Failing preflight is more honest than silently degrading coverage.
Why are there four fast rows rather than eight?
The design selects two high-value scenario combinations and runs them across two browsers; it avoids the full viewport × locale Cartesian product.
Why can screenshot hashes differ when the test passes?
Browsers and platforms can render legitimate pixel-level differences while preserving the same business behavior. Screenshots are diagnostic evidence here, not an equality assertion.
Why is data-testid used for the translated
button?
It preserves stable element identity while visible text changes by locale. Copy can still be asserted separately when copy is the requirement.
What state does
navigator.language describe?
The browser runtime environment, not necessarily the application locale selected by the synthetic AUT input.
Official references and version notes
- Selenium 4.47 release notes — stable binding/Grid baseline pinned for this chapter.
- Selenium downloads and supported platforms — current stable releases and browser/platform support links.
- Supported Browsers — browser-specific capability boundaries.
- Working with windows and tabs — standard window-size APIs used for responsive desktop rows.
- Browser Options — standard and browser-specific option/capability model.
- Selenium Grid — remote, parallel, cross-machine and cross-platform execution boundary.
- Getting started with Selenium Grid — local Grid prerequisites and Selenium Manager driver setup.
- WebDriver BiDi — current cross-browser event-stream direction; not required by the mandatory Chapter 16 matrix.
- Docker Selenium Grid — official container/Helm distribution entry point; containerized Grid is optional here.
- Safari specific functionality — Selenium-side SafariDriver setup and options.
- Apple: Enable WebDriver on macOS — Safari remote automation must be enabled on macOS.
- Apple: Testing with WebDriver in Safari — Apple-provided SafariDriver and WebDriver execution model.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.