Chapter 30Lesson 02~330 minutes

Capstone: Build and Operate a Production Cross-Browser Automation Platform: Guided Hands-On Workflow

Build a disposable local capstone with a synthetic AUT, maintainable Python Selenium tests, cross-browser or simulated matrix execution, optional local Grid, CI-compatible command, evidence capture, and an operating runbook.

Disposable AUTCross-browserpytestGrid optionEvidenceRunbook

Learning objectives

  • Generate a fully disposable local capstone workspace with a synthetic AUT and CI-compatible command.
  • Implement the canonical Python/pytest browser scenario with explicit waits, stable locators, failure-safe quit, redacted evidence, and per-test isolation.
  • Run at least two available browsers or use a faithful simulated matrix when a second binding/browser is unavailable.
  • Add an optional pinned local Grid/container execution path without making Docker or paid cloud mandatory.
  • Verify causality through returned capabilities, session IDs, URL/DOM state, test output, evidence files, and cleanup.

1. Build the disposable capstone workspace

The mandatory path is local and free. It creates a loopback-only AUT on 127.0.0.1:8790, synthetic @example.test identities, stable data-testid locators, a Page Object, a pytest test, evidence directories, a provider-neutral runner, and a runbook. Nothing points at production.

# file: make_capstone_platform.py
from pathlib import Path
import json

root = Path("selenium-capstone")
for name in ["app", "tests", "pages", "evidence", "downloads", "profiles", "scripts"]:
    (root / name).mkdir(parents=True, exist_ok=True)

(root / "requirements.txt").write_text("selenium==4.47.0\npytest==9.1.1\n", encoding="utf-8")
(root / "matrix.json").write_text(json.dumps({"pull_request":["chrome"],"scheduled":["chrome","firefox"],"fallback_when_second_browser_unavailable":"simulated capability lane"}, indent=2), encoding="utf-8")
(root / "app" / "server.py").write_text('from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nfrom urllib.parse import urlparse, parse_qs\nimport json\n\nSTATE = {"orders": {}}\nHTML = b"""<!doctype html><html><head><title>Capstone AUT</title></head><body>\n<h1>Checkout lab</h1>\n<label>Email <input data-testid=\'email\'></label>\n<button data-testid=\'submit\'>Create order</button>\n<p data-testid=\'status\' data-state=\'idle\'>idle</p>\n<script>\nconst email=document.querySelector(\'[data-testid=email]\');\nconst status=document.querySelector(\'[data-testid=status]\');\ndocument.querySelector(\'[data-testid=submit]\').addEventListener(\'click\', async()=>{\n  status.dataset.state=\'working\'; status.textContent=\'working\';\n  const r=await fetch(\'/api/order?email=\'+encodeURIComponent(email.value));\n  const data=await r.json();\n  status.dataset.state=data.state; status.textContent=data.state;\n});\n</script></body></html>"""\n\nclass H(BaseHTTPRequestHandler):\n    def log_message(self, fmt, *args): pass\n    def _json(self, code, obj):\n        body=json.dumps(obj).encode(); self.send_response(code)\n        self.send_header(\'Content-Type\',\'application/json\'); self.send_header(\'Content-Length\',str(len(body)))\n        self.end_headers(); self.wfile.write(body)\n    def do_GET(self):\n        u=urlparse(self.path)\n        if u.path==\'/health\': return self._json(200, {\'ok\':True})\n        if u.path==\'/api/order\':\n            email=parse_qs(u.query).get(\'email\',[\'\'])[0]\n            if not email.endswith(\'@example.test\'): return self._json(400, {\'state\':\'invalid\'})\n            if email in STATE[\'orders\']: return self._json(409, {\'state\':\'duplicate\'})\n            STATE[\'orders\'][email]=True; return self._json(200, {\'state\':\'created\'})\n        if u.path==\'/api/reset\': STATE[\'orders\'].clear(); return self._json(200, {\'state\':\'reset\'})\n        body=HTML; self.send_response(200); self.send_header(\'Content-Type\',\'text/html\'); self.send_header(\'Content-Length\',str(len(body))); self.end_headers(); self.wfile.write(body)\n\nThreadingHTTPServer((\'127.0.0.1\', 8790), H).serve_forever()\n', encoding="utf-8")
(root / "pages" / "checkout.py").write_text('from selenium.webdriver.common.by import By\nfrom selenium.webdriver.support.ui import WebDriverWait\n\nclass CheckoutPage:\n    EMAIL=(By.CSS_SELECTOR, \'[data-testid="email"]\')\n    SUBMIT=(By.CSS_SELECTOR, \'[data-testid="submit"]\')\n    STATUS=(By.CSS_SELECTOR, \'[data-testid="status"]\')\n    def __init__(self, driver): self.driver=driver\n    def create_order(self, email):\n        self.driver.find_element(*self.EMAIL).send_keys(email)\n        self.driver.find_element(*self.SUBMIT).click()\n        WebDriverWait(self.driver, 4).until(\n            lambda d: d.find_element(*self.STATUS).get_attribute(\'data-state\') in {\'created\',\'duplicate\',\'invalid\'}\n        )\n        return self.driver.find_element(*self.STATUS).get_attribute(\'data-state\')\n', encoding="utf-8")
(root / "tests" / "test_checkout.py").write_text("import json, os, uuid\nfrom pathlib import Path\nfrom importlib.metadata import version\nimport pytest\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options as ChromeOptions\nfrom selenium.webdriver.firefox.options import Options as FirefoxOptions\nfrom pages.checkout import CheckoutPage\n\nBASE_URL=os.getenv('BASE_URL','http://127.0.0.1:8790')\nEVIDENCE=Path('evidence'); EVIDENCE.mkdir(exist_ok=True)\n\ndef assert_safe_target(url):\n    if not (url.startswith('http://127.0.0.1:') or url.startswith('http://localhost:')):\n        raise RuntimeError(f'blocked non-lab target: {url}')\n\ndef new_driver(browser):\n    if browser=='firefox':\n        options=FirefoxOptions(); options.add_argument('-headless')\n        return webdriver.Firefox(options=options)\n    options=ChromeOptions(); options.add_argument('--headless=new')\n    return webdriver.Chrome(options=options)\n\n@pytest.mark.parametrize('browser', [os.getenv('BROWSER','chrome')])\ndef test_checkout(browser):\n    assert_safe_target(BASE_URL)\n    test_id=f'checkout-{browser}-{uuid.uuid4().hex[:8]}'\n    driver=None\n    try:\n        driver=new_driver(browser)\n        driver.get(BASE_URL)\n        caps=driver.capabilities\n        packet={\n          'test_id':test_id, 'selenium':version('selenium'), 'session_id':driver.session_id,\n          'browserName':caps.get('browserName'), 'browserVersion':caps.get('browserVersion'),\n          'platformName':caps.get('platformName'), 'url':driver.current_url, 'title':driver.title\n        }\n        (EVIDENCE/f'{test_id}-session.json').write_text(json.dumps(packet,indent=2),encoding='utf-8')\n        state=CheckoutPage(driver).create_order(f'{test_id}@example.test')\n        assert state=='created'\n    except Exception:\n        if driver is not None:\n            driver.save_screenshot(str(EVIDENCE/f'{test_id}-failure.png'))\n        raise\n    finally:\n        if driver is not None: driver.quit()\n", encoding="utf-8")
(root / "scripts" / "run.py").write_text("import os, subprocess, sys\nfrom pathlib import Path\n\nbrowser=os.getenv('BROWSER','chrome')\nprint('lane:', browser)\ncmd=[sys.executable,'-m','pytest','-q','tests/test_checkout.py']\nresult=subprocess.run(cmd, cwd=Path(__file__).resolve().parents[1])\nraise SystemExit(result.returncode)\n", encoding="utf-8")
(root / "runbook.md").write_text('# Capstone operating runbook\n\n1. Preflight versions, target, browser availability, Grid health, test identity and evidence path.\n2. Run the smallest smoke lane first.\n3. Preserve first-failure evidence before retries/restarts.\n4. Classify test/DOM/browser/Grid/CI/AUT/evidence layer.\n5. Apply the least destructive correction.\n6. Re-run the smallest controlled scenario, then expand the matrix.\n7. Redact/minimize evidence and enforce retention.\n8. Review flake/runtime/capacity/version budgets before release gating.\n', encoding="utf-8")
print(root.resolve())

Run python make_capstone_platform.py, then work inside selenium-capstone/. The generated requirements pin Selenium 4.47.0 so the binding baseline is explicit.

2. Preflight before a browser is created

Start the fixture with python app/server.py. Verify http://127.0.0.1:8790/health returns {"ok": true}. Then record Python/Selenium and installed browser availability. The target guard in the test rejects non-loopback URLs before session creation; this makes authorization a fail-closed control rather than a reminder in prose.

python --version
python -c "from importlib.metadata import version; print(version('selenium'))"
python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8790/health').read().decode())"

3. Canonical local run: one test, one session, one identity

From the capstone root, run BROWSER=chrome python scripts/run.py (PowerShell: $env:BROWSER="chrome"; python scripts/run.py). The pytest test creates one fresh browser, records session/capability evidence before the final assertion, uses a unique synthetic identity, explicitly waits for a terminal DOM state, captures a screenshot only on failure, and quits in finally.

Expected state

A passing run prints one pytest pass and creates a *-session.json evidence file. The AUT records only a synthetic order identity; no persistent personal browser profile is reused.

4. Add a second browser—or use the explicit simulation fallback

If Firefox is installed and supported, run the same command with BROWSER=firefox. The behavior specification stays the same: load the same AUT, create a unique order, assert created, and record returned capabilities. If a second browser is unavailable, use a plainly labeled capability-lane simulation rather than pretending that Chrome proves Firefox behavior.

# Deterministic fallback when only one browser is installed.
import json
lanes=[
  {"browserName":"chrome","execution":"live-if-available","required":"pull_request"},
  {"browserName":"firefox","execution":"simulated-capability-lane","required":"scheduled"},
]
print(json.dumps(lanes, indent=2))

The simulation satisfies the learning path, not browser compatibility certification. Production support for a browser still requires an actual supported lane somewhere in the delivery system.

5. Optional local Grid/container lane with a pinned image

The current official Docker Selenium repository publishes full tags such as selenium/standalone-chrome:4.47.0-20260808 and recommends --shm-size=2g for browser-containing containers. Keep the Grid bound to loopback for this lab.

services:
  selenium:
    image: selenium/standalone-chrome:4.47.0-20260808
    shm_size: 2gb
    ports:
      - "127.0.0.1:4444:4444"
# Browser containers cannot reach a host-only 127.0.0.1 AUT as "localhost".
# Put the AUT on the same private Compose network or use an explicitly reachable lab host.

The following example makes the Optional local Grid/container lane with a pinned image behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
driver = webdriver.Remote(command_executor="http://127.0.0.1:4444", options=options)
try:
    print("session:", driver.session_id)
    print("caps:", driver.capabilities)
finally:
    driver.quit()

Important networking boundary: a browser inside the container resolves its own localhost. To test the AUT from the container, place the AUT on the same private Compose network or configure an explicitly reachable disposable lab hostname. Do not “solve” this by exposing a production service or disabling network controls.

6. CI-compatible command, not CI-specific browser semantics

The command python scripts/run.py is intentionally provider-neutral. GitHub Actions, GitLab CI, Jenkins, or another runner should provision Python/browser/Grid, set BROWSER/BASE_URL, execute the same command, preserve its exit status, and publish evidence/ on failure. The browser meaning remains in the test code, not hidden inside provider YAML.

7. Add BiDi only where current support justifies it

For a supported browser/binding, enable BiDi at session creation and subscribe before the event you need. Keep the collector narrow and remove the handler before quitting. A missing high-level domain is a capability/support fact—not permission to import internal beta transport classes into the platform contract.

from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait

options = webdriver.ChromeOptions()
options.enable_bidi = True
driver = webdriver.Chrome(options=options)
entries=[]; handler_id=None
try:
    handler_id=driver.script.add_console_message_handler(entries.append)
    driver.get("data:text/html,<script>console.log('capstone-event')</script>")
    WebDriverWait(driver, 3).until(lambda _: entries)
    print("bidi_event:", getattr(entries[0], "text", str(entries[0])))
finally:
    if handler_id is not None:
        driver.script.remove_console_message_handler(handler_id)
    driver.quit()

8. Before/after causality checklist

The following table organizes the key choices and evidence for Before/after causality checklist. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Step Before After / proof
target guard no browser session authorized loopback target accepted or unsafe target rejected
driver creation no session session ID + returned capabilities
navigation blank/new tab URL/title + stable DOM marker
order action synthetic identity unused AUT status becomes created
evidence empty namespace session packet; screenshot only on failure
quit live browser process/session session closed; temporary runtime disposable
Grid lane free matching slot session placed on matching Node or queued with observable evidence

9. Small challenge: choose the correct control

A PR run has Chrome installed locally, the nightly Firefox Grid is currently unavailable, and the change only touches a server-rendered checkout label. Choose among: fail the PR because Firefox is absent; silently drop Firefox forever; use the Chrome PR smoke lane and keep Firefox as a visible scheduled requirement with explicit degraded-status evidence. The third choice preserves fast feedback without falsifying the support contract.

10. Cleanup

Stop the local AUT, quit every browser session, stop/remove any disposable Grid container, remove the lab workspace if you do not need the evidence, and never reuse its synthetic state as a production credential source. If evidence is retained, apply the retention/redaction rules from Chapters 17 and 24.

Next lesson

Capstone: Build and Operate a Production Cross-Browser Automation Platform: Configuration, Design Patterns, and Trade-Offs

Continue with Capstone: Build and Operate a Production Cross-Browser Automation Platform: 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 current-version notes

  • Selenium downloads — Current stable Selenium clients and Selenium Server/Grid 4.47.0, released August 10, 2026.
  • Selenium 4.47 release notes — Current Grid/BiDi/container-related release changes and version baseline.
  • Grid components — Router, New Session Queue, Distributor, Session Map, Event Bus, Nodes and session routing architecture.
  • Grid getting started — Current Grid prerequisites, capacity sizing guidance, and public-access security warning.
  • Grid CLI options — Current Node max-sessions, BiDi/CDP proxying and other version-specific Grid configuration.
  • Avoid sharing state — Current guidance on isolated data and a fresh WebDriver instance per test.
  • Page object models — Current guidance on UI service/locator centralization and keeping business assertions in tests.
  • WebDriver BiDi — Current bidirectional protocol guidance and evolving high-level browser observability/control surface.
  • Docker Selenium — Official images; current full tag examples use 4.47.0-20260808 and shared-memory guidance for browser containers.
Version and compatibility note

Version-sensitive statements in this lesson retain the pinned baseline used when the lesson was authored. Before changing Selenium, browser, driver, Grid, BiDi, container, or framework dependencies, compare that baseline with current primary documentation instead of silently substituting an unverified “latest” environment.

Knowledge checks

Why does the capstone test create a unique @example.test identity per run?

A browser container cannot reach http://127.0.0.1:8790. Is Selenium broken?

What should the CI provider execute?

If Firefox is unavailable locally, what does the simulation prove?

Why write session evidence before the final assertion?

Summary and next bridge

The capstone treats Selenium as one part of a governed browser-automation platform: explicit risk coverage, isolated sessions/data, current version evidence, measured Grid capacity, CI portability, privacy-aware diagnostics, secure boundaries, and evidence-driven incident/governance loops.

Next: Capstone: Build and Operate a Production Cross-Browser Automation Platform: Configuration, Design Patterns, and Trade-Offs

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.