PRACTICAL GUIDE / Java record Selenium capability profile
Safe Selenium capability profiles with Java records
Design a validated Java record for Selenium capability intent, copy mutable inputs safely, and translate every profile into fresh browser options.
In this guide6 sections
What you will learn
- Understand what a record does and does not protect
- Define stable intent and build new options every time
- Test value stability and request translation
- Diagnose failures at the boundary that owns them
A profile is used as a cache key, then a setup helper adds one argument to the list inside it. The record's hash changes, the cache can no longer find its own entry, and a later test builds a different browser request. The record keyword did not make the list immutable.
Records are excellent capability inputs when their components describe stable values and the constructor rejects ambiguity. They become dangerous when teams put ChromeOptions, mutable collections, or arbitrary capability maps inside them and assume concise syntax provides safety. The reliable design stores validated intent, takes defensive copies, and creates fresh Selenium options at the session boundary.
Understand what a record does and does not protect
A Java record gives you final component fields, accessors, a canonical constructor, and value-based equals(), hashCode(), and toString() derived from its components. Finality applies to each reference. It does not recursively freeze the object behind that reference. If a component points at an ArrayList, code holding the same list can still add or remove entries.
That distinction is especially important for value equality. A list's contents participate in its equality and hash code. Put a record containing that list into a HashMap, mutate the list, and the record's computed hash may no longer correspond to the bucket where the map stored it. The record itself was never reassigned, yet lookup behavior is broken because one component changed underneath it.
Selenium browser option classes are also mutable. Methods such as addArguments(), setBrowserVersion(), and setPageLoadStrategy() configure the instance. Storing a ChromeOptions component in a record only makes the reference final. Any caller with access to the options can still change the next request. Record equality over a mutable third-party class is not a useful configuration contract either.
Store plain intent instead. A browser enum, booleans, Selenium's PageLoadStrategy enum, normalized optional strings, and an immutable list are understandable inputs. Translate them just before creating a session. Two equal profiles should yield equivalent requests, but they should not yield the same mutable options object.
Validation belongs in the compact constructor when it defines whether the value can exist at all. A blank browser version and an absent browser version are different at the WebDriver wire level, but a framework may decide blank is never meaningful and reject it. A null Optional is also not “empty”; it is a broken caller contract. Normalize once instead of making every translator repeat the checks.
Do not overstate what constructor validation can prove. A nonblank browser version may still be unavailable. A platform string may not match any Grid node. A syntactically valid launch argument may not be supported by the installed browser. The record protects application-level invariants. Selenium, the driver, and the remote endpoint remain responsible for their runtime contracts.
The profile should also exclude values that change per attempt but do not affect the browser request. Test IDs, retry numbers, artifact paths, and timestamps belong to execution context. Including them in record equality makes identical browser intent look different and encourages caching or snapshots around incidental data.
Define stable intent and build new options every time
The following record supports Chrome and Firefox. Its extra arguments are explicitly browser-specific: the selected browser determines their meaning. The list is an escape hatch for a small reviewed set, not a claim that launch flags are portable.
The compact constructor checks required references, rejects blank optional matching values, rejects blank arguments, and copies the list. It preserves argument order because command-line order can matter. It does not sort or deduplicate values to make equality look cleaner.
package example.capabilities;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.openqa.selenium.PageLoadStrategy;
public record CapabilityProfile(
Browser browser,
boolean headless,
boolean acceptInsecureCerts,
PageLoadStrategy pageLoadStrategy,
Optional<String> browserVersion,
Optional<String> platformName,
List<String> extraArguments) {
public enum Browser {
CHROME,
FIREFOX
}
public CapabilityProfile {
Objects.requireNonNull(browser, "browser");
Objects.requireNonNull(pageLoadStrategy, "pageLoadStrategy");
browserVersion = normalized(browserVersion, "browserVersion");
platformName = normalized(platformName, "platformName");
extraArguments = List.copyOf(extraArguments);
if (extraArguments.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("extraArguments must not contain blanks");
}
}
private static Optional<String> normalized(
Optional<String> value, String fieldName) {
Objects.requireNonNull(value, fieldName);
return value.map(String::trim).map(text -> {
if (text.isEmpty()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
return text;
});
}
}List.copyOf() rejects a null list and null elements, then returns an unmodifiable list with the same encounter order. The explicit blank check catches empty or whitespace-only arguments with a message that points at the profile field. If the suite needs to accept an argument containing spaces after a prefix, that is not blank and remains untouched.
The acceptInsecureCerts field deserves a policy discussion. It is a standard WebDriver capability, but enabling it relaxes certificate checks for the session. Do not set it to true as a universal cure for test-environment TLS problems. Make the choice visible in the profile, default it to false in the external parser, and limit true to environments where the team intentionally tests through an untrusted certificate.
The translator creates a concrete options instance inside each switch arm. Common settings are repeated for clarity. A generic helper over AbstractDriverOptions can remove a few lines, but it can also make browser-specific changes harder to review. This is infrastructure code where a little duplication exposes intent.
package example.capabilities;
import java.net.URL;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
public final class CapabilityProfiles {
private CapabilityProfiles() {}
public static Capabilities toOptions(CapabilityProfile profile) {
return switch (profile.browser()) {
case CHROME -> chromeOptions(profile);
case FIREFOX -> firefoxOptions(profile);
};
}
public static WebDriver createRemote(URL gridUrl, CapabilityProfile profile) {
return new RemoteWebDriver(gridUrl, toOptions(profile));
}
private static ChromeOptions chromeOptions(CapabilityProfile profile) {
ChromeOptions options = new ChromeOptions();
options.setAcceptInsecureCerts(profile.acceptInsecureCerts());
options.setPageLoadStrategy(profile.pageLoadStrategy());
profile.browserVersion().ifPresent(options::setBrowserVersion);
profile.platformName().ifPresent(options::setPlatformName);
if (profile.headless()) options.addArguments("--headless=new");
options.addArguments(profile.extraArguments());
return options;
}
private static FirefoxOptions firefoxOptions(CapabilityProfile profile) {
FirefoxOptions options = new FirefoxOptions();
options.setAcceptInsecureCerts(profile.acceptInsecureCerts());
options.setPageLoadStrategy(profile.pageLoadStrategy());
profile.browserVersion().ifPresent(options::setBrowserVersion);
profile.platformName().ifPresent(options::setPlatformName);
if (profile.headless()) options.addArguments("-headless");
options.addArguments(profile.extraArguments());
return options;
}
}Selenium 4 uses browser options classes for session creation. ChromeOptions and FirefoxOptions already identify their browsers, so the remote constructor receives both the browser identity and configured common fields through the options object. The endpoint processes the request and, if it creates a session, returns the session's capabilities.
The factory returns Capabilities from the pure translation method to give tests a common type. It must not return one cached object. Selenium options remain mutable after construction. Fresh allocation keeps a caller that inspects or extends one request from altering another equal profile's output.
Headless mode illustrates why the profile is intent rather than wire representation. One boolean translates into a Chromium argument in the Chrome branch and a Firefox argument in the Firefox branch. The record's equality remains about the suite's requested behavior. It does not pretend that browser-specific serialization is identical.
Browser version and platform name take part in remote matching. The factory should pass explicit values without claiming which node will match them. Version comparison can be implementation-defined at the remote end under the WebDriver specification. Preserve the exact normalized request and inspect endpoint evidence if no session is created.
Test value stability and request translation
Good unit tests attack the reasons for using a record. Mutate the caller's original list and prove the record does not change. Put the profile in a map, mutate the source, and prove lookup still works. Translate the same value twice and prove the mutable options are distinct. Verify each browser selects its matching Selenium options class.
These tests run without a browser. They do not test Grid or driver startup, which keeps them fast and isolates record and factory defects. Each oracle observes behavior that a real regression can break.
package example.capabilities;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
final class CapabilityProfilesTest {
@Test
void sourceListMutationCannotChangeProfileOrHashLookup() {
ArrayList<String> source = new ArrayList<>(List.of("--disable-notifications"));
CapabilityProfile profile = profile(CapabilityProfile.Browser.CHROME, source);
HashMap<CapabilityProfile, String> cache = new HashMap<>();
cache.put(profile, "known");
source.add("--incognito");
assertEquals(List.of("--disable-notifications"), profile.extraArguments());
assertEquals("known", cache.get(profile));
}
@Test
void translationUsesMatchingTypeAndReturnsFreshOptions() {
CapabilityProfile chrome = profile(CapabilityProfile.Browser.CHROME, List.of());
CapabilityProfile firefox = profile(CapabilityProfile.Browser.FIREFOX, List.of());
assertInstanceOf(ChromeOptions.class, CapabilityProfiles.toOptions(chrome));
assertInstanceOf(FirefoxOptions.class, CapabilityProfiles.toOptions(firefox));
assertNotSame(
CapabilityProfiles.toOptions(chrome),
CapabilityProfiles.toOptions(chrome));
}
@Test
void blankMatchingValuesAreRejectedBeforeSeleniumRuns() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> new CapabilityProfile(
CapabilityProfile.Browser.CHROME,
true,
false,
PageLoadStrategy.NORMAL,
Optional.of(" "),
Optional.empty(),
List.of()));
assertEquals("browserVersion must not be blank", error.getMessage());
}
@Test
void blankExtraArgumentsAreRejectedBeforeSeleniumRuns() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> profile(
CapabilityProfile.Browser.CHROME,
List.of("--disable-notifications", " ")));
assertEquals("extraArguments must not contain blanks", error.getMessage());
}
private static CapabilityProfile profile(
CapabilityProfile.Browser browser, List<String> arguments) {
return new CapabilityProfile(
browser,
true,
false,
PageLoadStrategy.NORMAL,
Optional.empty(),
Optional.empty(),
arguments);
}
}The map test is meaningful because removing the defensive copy causes the source mutation to change the component list. Depending on the resulting hash, lookup may fail, and the direct list assertion always fails. The test is not relying on a duplicate-path check or another condition that can never change.
The two rejection tests cover different guards that are easy to mistake for one. normalized() protects the optional matching values, so a whitespace-only browserVersion is refused with a message naming that field. A separate stream check protects extraArguments, so a blank entry among otherwise valid launch flags is refused with its own message. Both assert the message, not merely the exception type, because both guards throw IllegalArgumentException and a test that only checked the class would pass while the wrong branch fired. Delete either guard and exactly one of these tests turns red, which is the property you want: a validation rule that no test can distinguish is a rule nobody is actually enforcing.
Add targeted translator assertions for settings your suite owns. For example, if acceptInsecureCerts=true is allowed only in a certificate test project, assert that the generated options expose that standard capability there and that normal profiles leave it false. Do not snapshot the entire options map. Selenium may add or change internal browser option details across releases, and large snapshots train reviewers to approve unexplained diffs.
A real session smoke test has another job. It creates a driver from a known profile, obtains returned capabilities from RemoteWebDriver, checks the reported browser against the requested browser policy, navigates to a controlled page, and quits in finally. Save the session ID with both the sanitized requested profile and the returned standard fields.
Do not log the record's generated toString() by default. Today its components may be harmless, but a future field could contain a proxy address, provider label, filesystem path, or credential-bearing value. Build an explicit diagnostic projection. Safe fields here are browser, headless, certificate policy, page-load strategy, presence rather than value of optional matching fields when values are sensitive, and approved argument names.
Diagnose failures at the boundary that owns them
The first worked failure is a changed hash key. A suite caches prepared configuration by profile. Another helper modifies the source ArrayList, and cache.get(profile) becomes unreliable. Inspect the profile's copied list and a unit test like the one above. Selenium logs are irrelevant because the object is corrupted before options are built. The fix is defensive copying or removing the cache, not a Grid retry.
A second failure occurs when a profile is stable but translation leaks. If toOptions() caches a ChromeOptions, one caller can add --incognito and a later caller receives it. The record remains equal and unchanged, so logging only the record hides the defect. The fresh-instance assertion identifies the real boundary. Log a sanitized translation view when session requests differ despite equal profiles.
The third case reaches Grid. A profile explicitly requests a platform or browser version for which no slot is available. Construction rejects or queues the request, so no returned session capabilities exist. Record the requested fields and inspect Grid status, node stereotypes, and the endpoint error. Do not manufacture a “returned” record from the request and do not remove the constraint automatically, because either action hides the requested coverage.
A near miss starts a session and then fails on navigation. At that point, the profile, options translation, and session handshake have all progressed. DNS, TLS, application availability, page-load strategy, and test synchronization are separate candidates. A permissive certificate profile may change navigation behavior, but the returned browser name alone cannot prove the certificate setting caused the failure. Use the browser and server evidence appropriate to the navigation.
Headless startup is another runtime boundary. The record can prove headless=true, and the translator test can prove the expected browser argument was added. A browser can still reject the argument, fail to start in the container, or render the application differently. Use driver logs and a real browser smoke test. Do not claim the Java type system validated a command-line switch.
Mutable nested values can recreate the original issue if the profile later gains a map. Map.copyOf() protects keys and value references, not mutable objects reachable through those values. When equality or hashing matters, recursively normalize supported structures or forbid them. An unrestricted Map<String, Object> is usually a sign that provider-specific configuration needs its own typed adapter.
Page-load strategy produces a useful fourth example because it is a real standard capability but is often blamed for the wrong wait. Selenium documents normal, eager, and none with different navigation blocking behavior. Choosing eager can let get() return while some resources are still loading, and none does not wait for page loading. None of those settings means the application-specific button, API result, or client-rendered component is ready. Keep explicit waits around the state the test needs.
If a migration changes NORMAL to EAGER, the record diff proves the request intent changed. A returned capability can help show what the session reports. The product failure still needs evidence at the application boundary, such as the element state and relevant server response. Reverting the strategy may hide a missing application wait by adding incidental delay, so do not call that a root-cause fix without showing why navigation readiness was the required condition.
Certificate policy has a similar near miss. A test fails on a TLS interstitial, and a developer sets acceptInsecureCerts=true. That may be an intentional test-environment choice, but it also stops testing normal certificate rejection in that session. Split certificate-behavior coverage from ordinary application coverage, record the profile value, and make the security trade-off visible. Do not let a shared default turn the relaxation on for every browser.
Argument conflicts should be rejected where the framework understands them. If the typed headless component already controls headless mode, allowing callers to add a second headless or headed argument through extraArguments creates two sources of truth. Maintain a small denylist for arguments that duplicate named fields, or remove the escape hatch and introduce typed components as needs become stable. The cost is more factory changes; the benefit is that the record has one meaning.
Equality itself can become too broad. Adding a diagnostic label, artifact directory, or human-readable test name as a record component changes equals() and hashCode() even though the browser request is the same. That may fragment caches and reports. Keep execution metadata in a companion record keyed by attempt ID. Capability equality should answer whether two pieces of browser intent are the same, not whether two test attempts have identical bookkeeping.
Equality can also be too narrow if derived behavior is hidden outside the record. Suppose environment code appends --no-sandbox only on Linux after toOptions() returns. Two equal profiles then produce requests that depend on ambient process state, and profile logs cannot explain the difference. Either model the approved deployment mode as explicit translation context or keep environment-specific adaptation in a named layer that logs its decision. Do not scatter post-translation mutations across fixtures.
When an incident report contains only the record's generated toString(), verify that the record was logged before or after normalization. A raw input of whitespace and a normalized optional value are not the same evidence. Prefer two events at the parser boundary: sanitized raw keys and the accepted profile projection. Constructor rejection should preserve the field name and failure category without echoing a secret value.
For a remote provider, the common record should stop before account-specific scheduling. Let a provider adapter take the common profile plus its own typed request, build a fresh namespaced object, and attach it to fresh options. Test that adapter for defensive copies and redaction independently. This keeps local Selenium runs from acquiring unused provider fields and prevents one provider's vocabulary from becoming the framework's universal capability model.
Returned capabilities are observations, not a normalized copy of the record. A profile may omit browser version and receive a concrete version. It may request a platform spelling that the endpoint reports in a canonical form. Compare according to documented semantics and test policy. Store request and response separately so later reporting does not erase which side supplied each value.
Migrate profiles without changing coverage silently
Begin by finding mutable inputs: shared ChromeOptions, DesiredCapabilities, argument lists, capability maps, and helpers that read environment variables on demand. Record which tests mutate them. Those mutations reveal real configuration dimensions that the new record must either model, reject, or leave in a specialized path.
Introduce a parser that converts external strings into CapabilityProfile. It should select the browser, parse exact booleans, normalize optional strings, and choose a reviewed page-load strategy. Fail before session creation on unknown browser names or malformed values. Keep secrets such as provider credentials and remote URLs outside the record.
Route one smoke project through the new translator and compare coverage intent with the old path. Compare browser, headless policy, certificate policy, version constraint, platform constraint, and approved arguments. Do not start two sessions per test for a shadow comparison; that changes capacity and scheduling. Unit-test translation, then run one real-session path at a time.
Remove setters and direct options access from callers as they migrate. A record that feeds a factory does not help if tests can still import a singleton options object and mutate it. Narrow package visibility or expose only the profile parser and session factory from the infrastructure module.
CI should compile and run record contract tests before spending a Grid slot. Then a small browser matrix should create sessions and exercise a controlled page. This example assumes the Maven smoke test reads the two shown system properties and that the project is compiled for Java 21.
name: capability-profile-contract
on: [pull_request]
jobs:
value-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- run: mvn -B -Dtest=CapabilityProfilesTest test
session-smoke:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome, firefox]
services:
selenium:
image: selenium/standalone-${{ matrix.browser }}:4.44.0-20260505
ports:
- 4444:4444
options: >-
--shm-size=2g
--health-cmd "/opt/bin/check-grid.sh --host 0.0.0.0 --port 4444"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- run: >-
mvn -B -Dtest=CapabilityProfileSmokeTest
-Dbrowser=${{ matrix.browser }}
-Dselenium.remote.url=http://localhost:4444 testPinning the browser image costs maintenance but keeps old commits reproducible. A floating tag can change the browser, driver, and Selenium server between two runs of the same code. Update the pin through a separate dependency change with the smoke matrix as evidence.
Track rejected profile inputs during rollout. A rejection often exposes an undocumented alias, a blank environment variable, or a caller that relied on a default. Decide each case explicitly. Do not weaken the compact constructor to accept every historical value, or the record becomes a shorter spelling for the old ambiguity.
Keep a short compatibility table during the migration: old input, normalized profile, translation owner, and removal status. The table documents decisions but should not become executable truth duplicated from the parser. Contract tests must exercise the parser itself. Delete each compatibility alias after its callers move so the accepted input surface shrinks instead of accumulating forever.
Review retries while changing the profile path. If a retry rebuilds from ambient environment rather than reusing the validated profile value, two attempts can request different browsers or policies. Capture one profile per attempt and give each attempt its own session record. A successful retry proves that its request succeeded; it does not rewrite the failed attempt's inputs.
The migration also changes equality semantics. Two profiles with equal components compare equal, which can simplify deduplication and reporting. Do not use that fact to share live drivers or mutable options. Equal requests can create separate sessions with different session IDs, installed versions, nodes, and application state.
Know when a record is the wrong container
Use an ordinary final class when construction needs multiple named factories, hidden derived fields, or behavior that record component exposure would make awkward. A record is not automatically more correct. Its public component list becomes part of the type's contract, and adding a component changes construction and equality.
Avoid a record that contains WebDriver, WebElement, ChromeOptions, or a mutable provider SDK object. Those are session-bound or request-bound resources with lifecycle and mutation semantics. A value carrier cannot turn them into values. Store them in scoped fixtures or factories instead.
Do not put secrets into a record that will be logged, used in assertion messages, or attached to reports through generated toString(). Credentials and access tokens need a separate secret source and redacted transport adapter. “It is only test code” is not a protection once CI artifacts are retained or shared.
A generic capability map is also a poor record component for a common profile. It admits misspelled standard keys, wrong value types, browser-specific options in the wrong branch, and mutable nested structures. Use named components for the supported contract. If a cloud provider needs its own namespaced object, give it a provider-specific record and translator.
Finally, do not add a profile cache until measurement shows translation cost matters. Constructing a few Java option objects is rarely the expensive part of a browser test; session startup and application work dominate. Caching mutable translation output adds invalidation and isolation risk. Keep the record reusable, keep options fresh, and let each session own the request produced for it.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Is a Java record deeply immutable?
Record components are only shallowly protected. Their fields are final, but an object stored in a component can still be mutable. Copy collections during construction and avoid mutable nested values when stable equality matters.
Why keep Selenium Options out of the record?
Selenium option classes expose mutating configuration methods and belong to one session request. Store plain validated intent in the record, then create a new ChromeOptions or FirefoxOptions for each session.
Can I use a capability profile record as a Map key?
Equality and hash codes include the record components, so every component must remain stable while the key is stored. Defensive copies protect the record from later mutation through a caller-owned collection.
How do returned capabilities differ from my profile?
Returned capabilities describe the session the remote end created, while the profile describes the request your framework intended. Log a safe subset of both and compare only fields whose matching semantics you understand.
Where should provider-specific Selenium capabilities live?
Provider-specific settings belong in an adapter with typed, validated input rather than a generic map on the common profile. That boundary can apply the provider's namespace and redact account or tunnel data from logs.
RELATED GUIDES
Continue the learning route
GUIDE 01
Selenium executeAsyncScript in Java
Selenium executeAsyncScript Java uses a final callback argument and the script timeout. Learn return conversion, error handling, and tested Java patterns.
GUIDE 02
Debug Java Classpath Conflicts in Selenium Frameworks
A practical guide to debug Java Selenium classpath conflicts, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 03
Selenium Java Cookie and Browser Storage Testing
Master Selenium Java cookie storage testing with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Selenium Java Grid Session Factory Architecture
A practical guide to Selenium Java grid session architecture, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 05
Selenium Java Tutorial: Build a Maintainable Test Suite
Selenium Java tutorial for beginners covering Maven setup, WebDriver, waits, TestNG, JUnit, page objects, debugging, CI, and reliable UI tests.