Skip to content

When the same selector appears twice on a WordPress admin page — Playwright strict mode and the .first habit

For sites where SSH isn’t available, we drive maintenance through Playwright by automating the WordPress admin UI. Plugin updates, theme updates, translation updates — all running fine. Until one day, only the Core (WordPress itself) update started failing.

The error was Locator.click: strict mode violation — familiar to anyone who’s used Playwright, the one that fires when a locator matches multiple elements. Tracing it back, the root cause turned out to be a structural quirk of the WordPress admin UI sitting right at the trap line. This post walks through the bug from the angle of Playwright’s strict mode and making .first a habit.

Strict mode violation is “your selector is too lenient” telling you something

Playwright locators run in strict mode by default. Code like page.locator('input[name="upgrade"]').click() will fail with this error if two or more matching elements exist on the page:

Locator.click: strict mode violation:
locator('input[name="upgrade"]') resolved to 2 elements:
  1) <input name="upgrade" value="Update Now" ...>  (top button)
  2) <input name="upgrade" value="Update Now" ...>  (bottom button)

There’s a reason Playwright is designed this way: “if your selector doesn’t pin down exactly one element, your code is buggy waiting to happen.” Allowing multi-matches silently invites a future-DOM change to click the wrong element. Strict mode is the safety net that tells you early: your selector is ambiguous.

WordPress admin renders the same selector at the top and the bottom

The problem was that WordPress’s update-core.php (the page that handles Core, plugin, theme, and translation updates together) renders the same update button twice — once at the top and once at the bottom of the page. The DOM, concretely:

<!-- Top of page -->
<form action="update-core.php?action=do-core-upgrade" method="post">
  <input name="upgrade" type="submit" value="Update Now">
</form>

<!-- ...various checklists... -->

<!-- Bottom of page (same selector, different input) -->
<form action="update-core.php?action=do-core-upgrade" method="post">
  <input name="upgrade" type="submit" value="Update Now">
</form>

It’s a generous UX call by WordPress — long admin pages are easier to navigate when the action button is reachable from both ends. But from an automation perspective, two elements match the same form structure.

core_update_btn = page.locator(
    'form[action*="do-core-upgrade"] input[name="upgrade"]'
)
core_update_btn.click()   # ← strict mode violation

WordPress developers aren’t writing this assuming someone will automate it, which is fair. But the people automating it need to always assume the same selector might appear twice on a WordPress admin page.

The fix is just adding .first

In Playwright, you say “I’m fine with multi-matches, just take the first” by chaining .first:

# Before: strict mode violation
core_update_btn.click()

# After: explicitly click the first element (the top button)
core_update_btn.first.click()

A one-line addition fixes it, but the decision to add this line carries design intent. Pinning to “take the first = use the top button” means if WordPress ever adds a third button at the bottom, behavior won’t change. It’s forward-compatibility baked in.

The top and bottom buttons in WordPress admin are guaranteed to be functionally identical, so either click produces the same outcome. We still pin to .first for reproducibility — every run hits the same element.

Plugin, theme, and translation flows already used .first

Digging in, I found that the plugin, theme, and translation update paths had already adopted .first. The update-core.php upper/lower duplication was known and handled.

The Core update logic alone, inside run_browser_update_flow(), had been left behind in the old style. During refactors or feature work, when “plugins switched to .first” and “themes switched to .first” went in, the same fix wasn’t applied to Core. Classic “forgot to fan it out”.

The lesson is the same as in the csh portability bug we wrote about and the three-layer defense for PHP deprecated noise: when you add .first somewhere, grep for similar click sites to see if the same fix is needed. We didn’t do that this time, and paid for it later.

Catch raw .click() in regression tests

Two choices for the fix: “just add .first to the Core update,” or “make a test that fails CI if any update-core.php click goes without .first.”

We went with the latter. A new tests/test_browser_core_update_strict_mode.py walks the AST of browser_utils.py and flags any raw .click() on update-core.php locators (sketch):

def test_core_update_locator_uses_first():
    """Verify update-core.php locators don't call .click() directly.
    Prevents Playwright strict-mode-violation regressions."""
    tree = ast.parse(BROWSER_UTILS_PY.read_text())
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        # Detect direct calls like core_update_btn.click()
        if (isinstance(node.func, ast.Attribute)
                and node.func.attr == 'click'
                and is_update_core_locator(node.func.value)):
            assert is_first_chained(node.func.value), \
                f"strict-mode-violation risk at line {node.lineno}"

What’s worth noting: the test verifies “the .first guard is in place” as a structural invariant, not “the button can be clicked” as a runtime check. Mocking a Playwright page for integration testing would be far heavier and slower than enforcing the syntactic rule via AST. The same AST-test pattern showed up in the test guarding paramiko’s look_for_keys.

Lessons — habits to keep when automating the WordPress admin

Three principles to take away:

  1. For WordPress admin clicks, make .first a default habit. UIs that render the same control at both ends are pervasive in WordPress (update screens, bulk-action dropdowns, “Save” and “Close” in modals, etc.). Starting from a template that already includes .first is faster than hitting strict-mode violations later
  2. Whenever you fix something in one place, grep for the same pattern. The reason Core update got left behind was that prior fixes to plugin/theme/translation flows weren’t fanned out. Make “grep for similar code on every fix” the muscle memory
  3. Lock regression with AST static analysis catching forbidden patterns. Rather than mocking Playwright at runtime, encode the discipline as a syntactic rule and let CI enforce it. Failing the build on a .click() without .first is a final line of defense you can trust

If you’re writing browser automation against the WordPress admin, assume “is the same selector rendered twice?” by default, and make .first a reflex. Long term, it’ll save you the time you’d otherwise burn debugging strict mode failures.