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.
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.
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.
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-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?
To isolate AUT state so parallel or repeated tests do not collide through a shared account/data record.
A browser container cannot reach http://127.0.0.1:8790. Is Selenium broken?
Not necessarily. 127.0.0.1 inside the container refers to the container itself; fix the lab network/service route.
What should the CI provider execute?
The same provider-neutral test command; the provider supplies environment and publishes artifacts but does not redefine WebDriver semantics.
If Firefox is unavailable locally, what does the simulation prove?
Only matrix/control-flow semantics. It does not certify Firefox compatibility; a real supported lane is still required for that claim.
Why write session evidence before the final assertion?
So a failing behavioral assertion still leaves browser/session/version context for diagnosis.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.