<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/feed.xml" rel="self" type="application/atom+xml" /><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/" rel="alternate" type="text/html" /><updated>2026-09-30T20:11:31+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/feed.xml</id><title type="html">Tech Talk with Veeresh</title><subtitle>Principal QA Architect · AI Test Architect · 20+ Years in Enterprise Quality Engineering. AI-driven test strategy, automation frameworks, and software quality insights.</subtitle><author><name>Veeresh Bikkaneti</name></author><entry><title type="html">Four Drawers, One Agent</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/four-drawers-one-agent/" rel="alternate" type="text/html" title="Four Drawers, One Agent" /><published>2026-09-29T00:00:00+00:00</published><updated>2026-09-29T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/four-drawers-one-agent</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/four-drawers-one-agent/"><![CDATA[<p>Here’s a stack diagram that sounds tidy until you try to ship it. A rules file, then a vector database, then a markdown knowledge format, then a context database, then a graph framework on top. Each box supposedly replaces the one under it.</p>

<p>They don’t.</p>

<p>A vector database does not know your architecture decision. A markdown rulebook does not remember that a teammate wants the gap named. A graph framework contains neither. It only decides which drawer opens next, and whether you are allowed to open one again.</p>

<p>The small version of this mix-up is already in the <a href="/techtalkwith-veeresh/ai/architecture/web-grounded-chatbot-chrome-on-device-ai-cloudflare-worker/">portfolio chatbot write-up</a>. Chrome’s on-device model can decide to call a tool. It cannot search the web by itself, and it cannot remember that you scoped a proxy last Thursday unless you stored that somewhere else. The enterprise picture is the same shape with more names on it.</p>

<figure class="drawer-fig" data-drawers="pick">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Map</p>
      <p class="drawer-heading">Four jobs, not four layers of one product</p>
    </div>
  </div>
  <div class="drawer-stage">
    <div class="drawer-asks">
      <button type="button" class="drawer-btn" data-go="0" data-note="A standing order. It is true before the question is asked.">Never put an API key in the page.</button>
      <button type="button" class="drawer-btn" data-go="1" data-note="A curated decision. Someone has to have written it down.">Did we ship web search, or only scope it?</button>
      <button type="button" class="drawer-btn" data-go="2" data-note="A fact about a person or a past session. The repo rules do not know it.">This teammate wants the gap named, not smoothed over.</button>
      <button type="button" class="drawer-btn" data-go="3" data-note="Not a place you store facts. The permission to go back before answering.">The blog and the decision file disagree. Look again.</button>
    </div>
    <p class="drawer-note" data-note-slot=""></p>
    <div class="drawer-grid cols-4">
      <div class="drawer-card" data-on="0">
        <strong>Rulebook</strong>
        <span class="drawer-path">AGENTS.md, CLAUDE.md</span>
        <p>How to behave in this repo.</p>
        <p class="drawer-status">Open this one</p>
      </div>
      <div class="drawer-card" data-on="1">
        <strong>Curated knowledge</strong>
        <span class="drawer-path">OKF files, and RAG beside them</span>
        <p>Facts with an owner, or a pile you search.</p>
        <p class="drawer-status">Open this one</p>
      </div>
      <div class="drawer-card" data-on="2">
        <strong>Memory</strong>
        <span class="drawer-path">OpenViking memories, skills</span>
        <p>What survived the last session.</p>
        <p class="drawer-status">Open this one</p>
      </div>
      <div class="drawer-card" data-on="3">
        <strong>The loop</strong>
        <span class="drawer-path">LangGraph, or a smaller cycle</span>
        <p>Which drawer opens next, including again.</p>
        <p class="drawer-status">Open this one</p>
      </div>
    </div>
  </div>
  <p class="drawer-caption">Click a question. The highlight is the drawer that should answer it. The other three stay shut on purpose.</p>
</figure>

<h2 id="the-rulebook-is-not-memory">The rulebook is not memory</h2>

<p>People hear “memory” and point at <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, because the file is sitting there when the session starts. That file is a standing order. It does not remember that the proxy was scoped and then not shipped. Ask it to, and the model will sound sure. That is worse than a short memory.</p>

<p>There are two files, and they are not twins.</p>

<p><a href="https://agents.md/">AGENTS.md</a> is plain markdown with no required fields. The site calls it a README for agents. The <a href="https://aaif.io/">Agentic AI Foundation</a>, under the Linux Foundation, stewards it. Codex, Cursor, and Gemini CLI read it as project instructions. Windsurf support shows up in older write-ups. Confirm it on the tool you actually run, rather than trusting a roundup from May.</p>

<p><code class="language-plaintext highlighter-rouge">CLAUDE.md</code> is Anthropic’s file for Claude Code. It can also be layered from a user or an org, which a repo file cannot override. Since version 2.1.277, announced around 18 Sep 2026, Claude Code reads <code class="language-plaintext highlighter-rouge">AGENTS.md</code> when no <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> is present. If both exist, it reads <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> and ignores <code class="language-plaintext highlighter-rouge">AGENTS.md</code>. That fallback was not on Bedrock, Vertex, or Foundry in the notes I could corroborate, and sessions that never fetch Anthropic’s feature flags may not get it either.</p>

<p>The symlink still works.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ln</span> <span class="nt">-s</span> AGENTS.md CLAUDE.md
</code></pre></div></div>

<p>Use it only when the two names must be the same bytes. The moment you want a Claude-only note, the symlink is the wrong tool, because you no longer have a place to put that note. A one-line <code class="language-plaintext highlighter-rouge">@AGENTS.md</code> import inside <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> keeps one body of rules and leaves room under it.</p>

<p>Keep the rulebook short enough to load every turn. Procedures belong in a skill file. Decisions belong in the next drawer. A 2,000-line constitution feels thorough and then gets skimmed, which is how the one rule you cared about goes missing.</p>

<figure class="drawer-fig" data-drawers="cycle" data-interval="3400">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Flow · rulebooks</p>
      <p class="drawer-heading">Who actually reads which file</p>
    </div>
    <button type="button" class="drawer-btn" data-play="">Play</button>
  </div>
  <div class="drawer-stage">
    <div class="drawer-steps">
      <button type="button" class="drawer-btn" data-go="0">Only AGENTS.md</button>
      <button type="button" class="drawer-btn" data-go="1">Both files exist</button>
      <button type="button" class="drawer-btn" data-go="2">CLAUDE.md imports</button>
    </div>
    <div class="drawer-panel" data-panel="0" data-note="Claude Code 2.1.277 reads AGENTS.md, because CLAUDE.md is absent. Codex, Cursor, and Gemini CLI were already reading it.">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col is-choice">
          <p class="drawer-k">Shared file</p>
          <strong>AGENTS.md</strong>
          <p>Plain markdown. No schema. The README for agents. CLAUDE.md is not in the repo.</p>
        </div>
        <div class="drawer-col is-choice">
          <p class="drawer-k">Readers</p>
          <strong>Everyone, including Claude Code</strong>
          <p>One body of rules. The absence of CLAUDE.md is the feature.</p>
        </div>
      </div>
    </div>
    <div class="drawer-panel" data-panel="1" hidden="" data-note="If both files exist, Claude Code reads CLAUDE.md and ignores AGENTS.md. The other tools still read AGENTS.md. Two files means two sources of truth.">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col">
          <p class="drawer-k">Shared file, ignored by Claude</p>
          <strong>AGENTS.md</strong>
          <p>Still the file Codex, Cursor, and Gemini CLI open.</p>
        </div>
        <div class="drawer-col is-choice">
          <p class="drawer-k">Wins for Claude Code</p>
          <strong>CLAUDE.md</strong>
          <p>Anthropic's file. Present, so it wins, and the shared rules are skipped.</p>
        </div>
      </div>
    </div>
    <div class="drawer-panel" data-panel="2" hidden="" data-note="A one-line @AGENTS.md import. Claude-only notes can sit under that line. Everyone else keeps reading AGENTS.md directly.">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col is-choice">
          <p class="drawer-k">The real rulebook</p>
          <strong>AGENTS.md</strong>
          <p>One body of rules. This is the file you edit.</p>
        </div>
        <div class="drawer-col is-choice">
          <p class="drawer-k">Thin shim</p>
          <strong>CLAUDE.md → @AGENTS.md</strong>
          <p>Claude reads the import, then any Claude-only lines under it.</p>
        </div>
      </div>
    </div>
    <p class="drawer-note" data-note-slot=""></p>
  </div>
  <p class="drawer-caption">Play walks the three setups. The useful one is the third: AGENTS.md holds the rules, and CLAUDE.md only adds Claude-specific notes.</p>
</figure>

<h2 id="two-ways-to-know-something">Two ways to know something</h2>

<p>Retrieval-augmented generation is the honest tool for a pile. You split documents, embed the chunks, store the vectors, and pull back whatever sits near the question. It retrieves neighbors, not answers. Two chunks can be close in meaning and still contradict each other. Nothing in the pattern gives you freshness unless you built freshness yourself.</p>

<p>The Open Knowledge Format is the other job. Google Cloud published it on 12 Jun 2026. Sam McVeety and Amir Hormati described a vendor-neutral bundle: a directory of markdown files with YAML frontmatter. No SDK. No account. I read the spec in the <a href="https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md">open-knowledge-format repo</a> rather than the announcement recap. As of that spec, v0.2, the only required field is still <code class="language-plaintext highlighter-rouge">type</code>. A file with nothing else is conformant.</p>

<p>v0.2 puts the useful optional fields where an agent can see them without guessing. <code class="language-plaintext highlighter-rouge">sources</code> for where a fact came from, <code class="language-plaintext highlighter-rouge">generated</code> and <code class="language-plaintext highlighter-rouge">verified</code> for who produced it and who checked it, <code class="language-plaintext highlighter-rouge">status</code> and <code class="language-plaintext highlighter-rouge">stale_after</code> for whether it is still current. I am confident about those names because they are in <code class="language-plaintext highlighter-rouge">SPEC.md</code>. I did not re-implement a consumer.</p>

<p>Here is the part the tidy diagram gets wrong. OKF is not a graph database, and it does not replace a vector index. A link from one file to another is an ordinary markdown link. The spec says the kind of relationship lives in the sentence around the link, not in the link. A tool may draw those links as edges. The edge type was never in a schema. That is a gift if your review process is a pull request. It is a disappointment if you wanted SPARQL.</p>

<p>It is a good fit for things a person would actually curate. A table catalog. A runbook. An architecture decision. The kind of short product note a trio does not want re-litigated next sprint. It is a bad fit for every PDF and every Slack thread. That pile stays in RAG. Tell the agent, in the rulebook, that a retrieved chunk is evidence. The decision file is policy.</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">Architecture Decision</span>
<span class="na">title</span><span class="pi">:</span> <span class="s">Web search stays behind a proxy</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">The on-device model may call searchWeb. The key never ships in the page.</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">draft</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">chatbot</span><span class="pi">,</span> <span class="nv">secrets</span><span class="pi">]</span>
<span class="na">stale_after</span><span class="pi">:</span> <span class="s">2026-12-01T00:00:00Z</span>
<span class="nn">---</span>

The Prompt API can request a tool. It does not search the web.
Any key in client JavaScript is public. If we build search, the key
lives in one server-side proxy. That proxy is not in production.
</code></pre></div></div>

<p>That sketch follows OKF v0.2. <code class="language-plaintext highlighter-rouge">status: draft</code> matches the spec’s lifecycle values: draft, stable, deprecated. The June announcement used <code class="language-plaintext highlighter-rouge">timestamp</code> in its examples. Prefer <code class="language-plaintext highlighter-rouge">stale_after</code> if you are writing against v0.2. Do not copy the announcement’s frontmatter blindly into a current bundle.</p>

<figure class="drawer-fig" data-drawers="cycle" data-interval="2400">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Flow · knowledge</p>
      <p class="drawer-heading">Search the pile, or open the file</p>
    </div>
    <button type="button" class="drawer-btn" data-play="">Play</button>
  </div>
  <div class="drawer-stage">
    <div class="drawer-steps">
      <button type="button" class="drawer-btn drawer-btn-icon" data-go="0" aria-label="Step 1">1</button>
      <button type="button" class="drawer-btn drawer-btn-icon" data-go="1" aria-label="Step 2">2</button>
      <button type="button" class="drawer-btn drawer-btn-icon" data-go="2" aria-label="Step 3">3</button>
      <button type="button" class="drawer-btn drawer-btn-icon" data-go="3" aria-label="Step 4">4</button>
    </div>
    <div class="drawer-panel" data-panel="0">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col"><p class="drawer-k">RAG · vector search</p><strong>A pile of writing</strong><p>The Sept 25 post, old tickets, a Slack export.</p></div>
        <div class="drawer-col is-choice"><p class="drawer-k">OKF · linked markdown</p><strong>A directory you can open</strong><p>decisions/index.md lists the files that count.</p></div>
      </div>
    </div>
    <div class="drawer-panel" data-panel="1" hidden="">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col"><p class="drawer-k">RAG · vector search</p><strong>Cut into chunks</strong><p>Paragraphs become vectors. The decision's status may split in half.</p></div>
        <div class="drawer-col is-choice"><p class="drawer-k">OKF · linked markdown</p><strong>Follow the link</strong><p>index.md links to web-search.md. The link is the relationship.</p></div>
      </div>
    </div>
    <div class="drawer-panel" data-panel="2" hidden="">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col"><p class="drawer-k">RAG · vector search</p><strong>Nearest neighbor</strong><p>You get a paragraph that sounds like the decision.</p></div>
        <div class="drawer-col is-choice"><p class="drawer-k">OKF · linked markdown</p><strong>The file itself</strong><p>You get the file someone reviewed. Status is a field, not a vibe.</p></div>
      </div>
    </div>
    <div class="drawer-panel" data-panel="3" hidden="">
      <div class="drawer-split drawer-copy">
        <div class="drawer-col"><p class="drawer-k">Evidence</p><strong>A paragraph</strong><p>The model can decide to call a tool. It does not come with one.</p></div>
        <div class="drawer-col is-choice"><p class="drawer-k">The decision</p><strong>A status</strong><p>status: scoped. The key lives in a proxy. Not shipped.</p></div>
      </div>
    </div>
  </div>
  <p class="drawer-caption">Same question, two retrieval styles. The left side can quote you. The right side can tell you what was decided.</p>
</figure>

<h2 id="the-notebook-that-survives-the-session">The notebook that survives the session</h2>

<p><a href="https://github.com/volcengine/OpenViking">OpenViking</a> is an open-source context database from Volcengine. The interface is a virtual filesystem under <code class="language-plaintext highlighter-rouge">viking://</code>. You can list, read, and search. The docs sort context into three kinds, not two.</p>

<ul>
  <li><strong>Resources.</strong> Documents and other reference material you put there. Relatively static.</li>
  <li><strong>Memories.</strong> What the agent extracts from sessions: profile, preferences, entities, events, plus identity and soul for the assistant’s own continuity. These update. You do not hand-author every line.</li>
  <li><strong>Skills.</strong> Reusable instructions for how to do a job. Closer to a procedure than to a recollection.</li>
</ul>

<p>I went looking for “episodic” and “semantic” in those docs, because that is how a lot of agent write-ups sort memory. The words are not the product’s. The translation is still useful if you label it as a translation. The session log, before you commit it, is the episodic scratchpad. Extracted events and preferences are what people mean by long-term memory. Skills are the procedural drawer. Resources overlap the knowledge drawer more than they overlap memory. Use the analogy in a design review if it helps. Do not grep the docs for “episodic” and conclude the feature is missing.</p>

<p>The other correction: OpenViking is not a vector database you threw away. The storage doc splits content (AGFS, their filesystem layer) from a vector index that stores URIs and vectors, not the file body. Retrieval can be semantic. Reading is still “open this path.” Directories carry summaries. L0 is an abstract, L1 an overview, L2 the source. The agent is supposed to scan labels and open one file, not inhale the tree.</p>

<p>Their README reports a LoCoMo user-memory result: integrations landing around 80 to 83 percent, up from roughly 24 to 57 percent on native memory, with large token and latency drops. That is the project’s number. I did not re-run it. Treat it as a claim on the tin, not as your benchmark.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>viking://resources/decisions/web-search
viking://user/{user}/memories/events/2026-09-25-proxy-scoped
viking://user/{user}/memories/preferences/tone
viking://agent/skills/cite-the-file
</code></pre></div></div>

<figure class="drawer-fig" data-drawers="cycle" data-interval="2600">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Flow · memory</p>
      <p class="drawer-heading">Read the label before you open the folder</p>
    </div>
    <button type="button" class="drawer-btn" data-play="">Play</button>
  </div>
  <div class="drawer-stage">
    <div class="drawer-steps">
      <button type="button" class="drawer-btn" data-go="0">L0 · Abstract</button>
      <button type="button" class="drawer-btn" data-go="1">L1 · Overview</button>
      <button type="button" class="drawer-btn" data-go="2">L2 · Source</button>
    </div>
    <div class="drawer-split">
      <div>
        <p class="drawer-path">viking://</p>
        <div class="drawer-card is-on">
          <span class="drawer-path">resources/decisions/web-search</span>
          <div class="drawer-panel" data-panel="0"><p class="drawer-copy">Proxy scoped, not shipped. One line, enough to skip the other files.</p></div>
          <div class="drawer-panel" data-panel="1" hidden=""><p class="drawer-copy">Architecture decision. Client may call searchWeb. The key stays in a proxy. Related write-up: 25 Sep 2026. Still not a deploy.</p></div>
          <div class="drawer-panel" data-panel="2" hidden=""><p class="drawer-copy">Full file. Status, the reason a static page cannot hold the key, and the line that says the proxy was never put in production. Load this only if L1 was not enough.</p></div>
        </div>
        <div class="drawer-card"><span class="drawer-path">memories/preferences/tone</span><p>Name the gap. Don't smooth it.</p></div>
        <div class="drawer-card"><span class="drawer-path">memories/events/2026-09-25</span><p>Scoping session. Proxy left unfinished.</p></div>
        <div class="drawer-card"><span class="drawer-path">agent/skills/cite-the-file</span><p>Quote the decision file before the blog.</p></div>
      </div>
      <div class="drawer-col is-choice">
        <p class="drawer-k">What the agent should load</p>
        <div class="drawer-panel" data-panel="0"><p class="drawer-copy"><strong>Ten labels, one line each.</strong> Stop here if the abstract already answers the question.</p></div>
        <div class="drawer-panel" data-panel="1" hidden=""><p class="drawer-copy"><strong>One overview.</strong> Enough shape to know this file is the decision, and that it is not a deploy.</p></div>
        <div class="drawer-panel" data-panel="2" hidden=""><p class="drawer-copy"><strong>One source file.</strong> The argument, the status, the date. Not the rest of the tree.</p></div>
      </div>
    </div>
  </div>
  <p class="drawer-caption">OpenViking keeps content in a filesystem and a vector index beside it. L0, L1, and L2 are how you avoid stuffing the whole tree into the prompt.</p>
</figure>

<h2 id="the-floor-manager-not-the-filing-cabinet">The floor manager, not the filing cabinet</h2>

<p>LangChain is a box of adapters. Loaders, retrievers, model calls. Useful when you want one style of code across a lot of vendors. Skippable when you are reading three files and calling one model. Importing it does not give you judgment.</p>

<p>LangGraph is the piece that can go backwards. State lives on the graph. Edges can branch. A node is allowed to run again. You want that the moment some step may say “this isn’t enough.” If the agent does one tool call and answers, you already have an orchestrator. The browser bot in that September post is the whole thing. Hauling in a graph library so the architecture slide looks finished is how small systems get heavy.</p>

<p>The hybrid people describe is a design, not a product you install. Nothing ships as “the stack.” A graph can read the rulebook at the start of the session, query OpenViking, open an OKF file by path, and run a vector search for the messy pile. The grade node is the one that earns the dependency. Without it you have a parade.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Sketch of the back edge. Not a full integration.
</span><span class="k">def</span> <span class="nf">grade</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
    <span class="n">state</span><span class="p">[</span><span class="s">"enough"</span><span class="p">]</span> <span class="o">=</span> <span class="nb">any</span><span class="p">(</span><span class="s">"status: scoped"</span> <span class="ow">in</span> <span class="n">note</span> <span class="k">for</span> <span class="n">note</span> <span class="ow">in</span> <span class="n">state</span><span class="p">[</span><span class="s">"notes"</span><span class="p">])</span>
    <span class="k">return</span> <span class="n">state</span>

<span class="k">def</span> <span class="nf">route</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">END</span> <span class="k">if</span> <span class="n">state</span><span class="p">[</span><span class="s">"enough"</span><span class="p">]</span> <span class="k">else</span> <span class="s">"read_decision_again"</span>
</code></pre></div></div>

<figure class="drawer-fig" data-drawers="cycle" data-interval="1600">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Flow · orchestration</p>
      <p class="drawer-heading">The grade step is allowed to send you back</p>
    </div>
    <button type="button" class="drawer-btn" data-play="">Play</button>
  </div>
  <div class="drawer-stage">
    <div class="drawer-flowrow">
      <button type="button" class="drawer-node" data-go="0" data-note="Already loaded at session start. Not searched again."><strong>01 Rules</strong><p>Standing orders are in the room.</p></button>
      <span class="drawer-line" data-on="0"></span>
      <button type="button" class="drawer-node" data-go="1" data-note="Last session said: scoped, not shipped."><strong>02 Memory</strong><p>The notebook still has Thursday.</p></button>
      <span class="drawer-line" data-on="1"></span>
      <button type="button" class="drawer-node" data-go="2" data-on="2 5" data-note="Open the decision file by link, not by vibe."><strong>03 OKF</strong><p>The file with a status.</p></button>
      <span class="drawer-line" data-on="2"></span>
      <button type="button" class="drawer-node" data-go="3" data-note="Pull the blog paragraph as color, not policy."><strong>04 RAG</strong><p>Evidence, not the decision.</p></button>
      <span class="drawer-line" data-on="3"></span>
      <button type="button" class="drawer-node" data-go="4" data-note="They agree. Answer, and cite the file. Status: scoped, not shipped."><strong>05 Grade</strong><p>Do the notes agree?</p></button>
    </div>
    <p class="drawer-note" data-note-slot=""></p>
    <button type="button" class="drawer-btn" data-go="5" data-note="Grade failed. Return to the curated file, or say you do not have a decision. Do not promote a blog chunk.">Show the back edge</button>
    <div class="drawer-return" data-on="5">
      <strong>Back edge</strong>
      <p>Not enough to answer. The loop returns to the decision file. A pipeline has no polite way to refuse.</p>
    </div>
  </div>
  <p class="drawer-caption">The sixth beat is the point. Without it, a confident paragraph from the blog becomes the decision.</p>
</figure>

<h2 id="one-question-all-four-drawers">One question, all four drawers</h2>

<p>Picture a new teammate on a Monday, except the teammate is the agent. You do not hand them the company drive. You hand them the house rules, the decision log, and a notebook. If that still is not enough, they search the archive, then they come back and say what they found before they guess.</p>

<p>The question on the table: did we already ship web search for the portfolio bot?</p>

<figure class="drawer-fig" data-drawers="pick">
  <div class="drawer-top">
    <div>
      <p class="drawer-kicker">Walkthrough</p>
      <p class="drawer-heading">Did we already ship web search?</p>
    </div>
  </div>
  <div class="drawer-stage">
    <div class="drawer-steps">
      <button type="button" class="drawer-btn" data-go="0">1. Rulebook</button>
      <button type="button" class="drawer-btn" data-go="1">2. Memory</button>
      <button type="button" class="drawer-btn" data-go="2">3. OKF</button>
      <button type="button" class="drawer-btn" data-go="3">4. RAG</button>
      <button type="button" class="drawer-btn" data-go="4">5. Grade</button>
      <button type="button" class="drawer-btn" data-go="5">6. Back edge</button>
    </div>
    <div class="drawer-panel" data-panel="0">
      <div class="drawer-copy">
        <p class="drawer-k">Rulebook</p>
        <strong>The orders are already in the room</strong>
        <p>AGENTS.md was loaded when the session opened. It says: cite the file that owns a decision, and never describe scoped work as shipped. That does not answer the teammate. It only forbids the lazy answer.</p>
      </div>
    </div>
    <div class="drawer-panel" data-panel="1" hidden="">
      <div class="drawer-copy">
        <p class="drawer-k">Memory</p>
        <strong>The notebook still has last Thursday</strong>
        <p>A committed session extracted an event: the proxy was designed and left unfinished. A preference sits beside it: name the gap. Neither line is in the rulebook. Both die if you only keep a chat transcript in a tab you already closed.</p>
      </div>
    </div>
    <div class="drawer-panel" data-panel="2" hidden="">
      <div class="drawer-copy">
        <p class="drawer-k">OKF</p>
        <strong>Open the file, not a similar paragraph</strong>
        <p>decisions/web-search.md has a type, a status, and a body. Status is scoped. The on-device model may request searchWeb. Any key in the page is public, so the key lives in one proxy, and that proxy was not deployed. This file is allowed to win.</p>
      </div>
    </div>
    <div class="drawer-panel" data-panel="3" hidden="">
      <div class="drawer-copy">
        <p class="drawer-k">RAG</p>
        <strong>The blog is evidence, not policy</strong>
        <p>Vector search finds the Sept 25 post. The paragraph is right: tool calling is not web search. Useful, and still a write-up. If the post and the file ever diverge, the file wins.</p>
      </div>
    </div>
    <div class="drawer-panel" data-panel="4" hidden="">
      <div class="drawer-copy">
        <p class="drawer-k">Grade</p>
        <strong>They agree, so you may speak</strong>
        <p>Memory and the decision file say the same status. The blog does not contradict them. Answer: scoped, not shipped. Point at the file. Mention the post as the write-up. Do not tell the teammate that search is live.</p>
      </div>
    </div>
    <div class="drawer-panel" data-panel="5" hidden="">
      <div class="drawer-copy">
        <p class="drawer-k">Back edge</p>
        <strong>Suppose nobody wrote the file</strong>
        <p>Then the grade fails. The blog chunk is not promoted into a decision because it ranked well. The loop returns, or it stops and asks a human. That refusal is the product.</p>
      </div>
    </div>
    <div class="drawer-actions">
      <button type="button" class="drawer-btn" data-prev="">Back</button>
      <button type="button" class="drawer-btn is-primary" data-next="">Next</button>
    </div>
  </div>
  <p class="drawer-caption">Step it. The point is the order, and the stop at the end.</p>
</figure>

<h2 id="same-loop-smaller-for-a-product-trio">Same loop, smaller, for a product trio</h2>

<p>A BA, a PO, and a PM sharing one discovery loop already know this shape. They just rarely file it where an agent can open it on Thursday.</p>

<p>The rulebook is the trio’s definition of evidence. A slide is not a decision. A hunch is not an interview. The curated drawer is the opportunity note or the ADR: one markdown file, a status, a date, a link to the conversation that changed it. The notebook is what the last few customer conversations actually moved, so you do not re-derive last week’s argument from whoever talks loudest in the standup. The back edge is build, measure, learn. If the note and the newest conversation disagree, you update the file or you stop. You do not ship the story that sounds smoother.</p>

<p>A loop that cannot end in “we were wrong” is a pipeline with extra boxes. That is as true for a sprint as it is for LangGraph.</p>

<h2 id="which-drawer-to-open">Which drawer to open</h2>

<p>If you only keep one table from this, keep this one. The middle column is the job. The right column is the confident mistake.</p>

<table>
  <thead>
    <tr>
      <th>You need</th>
      <th>Open</th>
      <th>Not</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>How to behave in this repo</td>
      <td><code class="language-plaintext highlighter-rouge">AGENTS.md</code>, short</td>
      <td>A vector chunk of the README</td>
    </tr>
    <tr>
      <td>Claude-only notes</td>
      <td><code class="language-plaintext highlighter-rouge">CLAUDE.md</code> that imports <code class="language-plaintext highlighter-rouge">AGENTS.md</code></td>
      <td>A second full copy</td>
    </tr>
    <tr>
      <td>A fact with an owner and a status</td>
      <td>An OKF file</td>
      <td>Whatever ranked nearest</td>
    </tr>
    <tr>
      <td>A pile of docs, tickets, PDFs</td>
      <td>RAG</td>
      <td>Hand-linking four thousand files</td>
    </tr>
    <tr>
      <td>What this person prefers, what happened last session</td>
      <td>OpenViking memory, after a commit</td>
      <td>The chat tab you closed</td>
    </tr>
    <tr>
      <td>A procedure worth repeating</td>
      <td>A skill file</td>
      <td>Hoping the model remembers the steps</td>
    </tr>
    <tr>
      <td>Branching, retries, a refusal</td>
      <td>LangGraph, or any explicit loop</td>
      <td>A single linear chain</td>
    </tr>
    <tr>
      <td>One tool call in a browser</td>
      <td>The tool-calling loop you already have</td>
      <td>A graph library</td>
    </tr>
  </tbody>
</table>

<h2 id="what-id-actually-do">What I’d actually do</h2>

<ol>
  <li>Write <code class="language-plaintext highlighter-rouge">AGENTS.md</code> as the real rulebook. If Claude needs an extra note, import the shared file. Don’t maintain two constitutions.</li>
  <li>Put any fact you will not tolerate the model inventing into a small markdown file with a type and a status. Link it from an index. That is the whole OKF idea.</li>
  <li>Leave the unstructured pile in RAG, and say so in the rulebook. Retrieved text is evidence.</li>
  <li>Commit sessions into a context store if the agent must know a person across conversations. OpenViking fits when you want that store to look like folders.</li>
  <li>Add a back edge only when a grade step is allowed to fail. Otherwise keep the loop you can read in one screen.</li>
</ol>

<h2 id="whats-verified-whats-a-translation">What’s verified, what’s a translation</h2>

<p>High means I checked a primary page, or several independent write-ups of a named release. Medium means I am passing on the project’s own number, or a detail I would re-read before betting a design on it. “Don’t say this” means the tidy version was wrong.</p>

<table>
  <thead>
    <tr>
      <th>Claim</th>
      <th>Confidence</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>AGENTS.md is stewarded by the Agentic AI Foundation under the Linux Foundation.</td>
      <td>High</td>
      <td>Stated on agents.md. Markdown, no required fields.</td>
    </tr>
    <tr>
      <td>Claude Code 2.1.277 reads AGENTS.md only when CLAUDE.md is absent. If both exist, CLAUDE.md wins.</td>
      <td>High</td>
      <td>Claude Code team announcement, reported 18–19 Sep 2026. Not on Bedrock, Vertex, or Foundry in those notes. I did not re-quote Anthropic’s raw changelog.</td>
    </tr>
    <tr>
      <td>A symlink or an <code class="language-plaintext highlighter-rouge">@AGENTS.md</code> import keeps one rulebook.</td>
      <td>High</td>
      <td>Both are long-standing Claude Code workarounds. The import is the one that still lets you add Claude-only lines.</td>
    </tr>
    <tr>
      <td>OKF was introduced 12 Jun 2026. v0.2’s only required frontmatter key is <code class="language-plaintext highlighter-rouge">type</code>.</td>
      <td>High</td>
      <td>Google Cloud blog for the date and authors. <code class="language-plaintext highlighter-rouge">SPEC.md</code> for the required key and for <code class="language-plaintext highlighter-rouge">sources</code>, <code class="language-plaintext highlighter-rouge">generated</code>, <code class="language-plaintext highlighter-rouge">verified</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">stale_after</code>.</td>
    </tr>
    <tr>
      <td>OKF links are a typed knowledge graph you can query.</td>
      <td>Don’t say this</td>
      <td>Links are markdown links. The relationship kind lives in the prose. There is no required SDK.</td>
    </tr>
    <tr>
      <td>OpenViking is a context database with <code class="language-plaintext highlighter-rouge">viking://</code>, and context is resources, memories, and skills.</td>
      <td>High</td>
      <td>docs.openviking.ai, checked Sep 2026. Memory subtypes include profile, preferences, entities, events, identity, and soul.</td>
    </tr>
    <tr>
      <td>OpenViking’s memory model is episodic versus semantic.</td>
      <td>Translation</td>
      <td>Useful analogy. Not the vocabulary in the docs. Skills are the procedural piece.</td>
    </tr>
    <tr>
      <td>OpenViking replaces the vector database.</td>
      <td>Don’t say this</td>
      <td>Storage docs describe AGFS for content plus a vector index of URIs. L0, L1, and L2 are how you avoid reading everything.</td>
    </tr>
    <tr>
      <td>LoCoMo accuracy of about 80–83% with large token savings.</td>
      <td>Medium</td>
      <td>Printed in the OpenViking README. Not reproduced for this essay.</td>
    </tr>
    <tr>
      <td>LangGraph can cycle and branch on state. LangChain is optional plumbing.</td>
      <td>High</td>
      <td>That is the frameworks’ actual split. The hybrid “stack” is a design, not a single install.</td>
    </tr>
  </tbody>
</table>

<h2 id="citations--further-reading">Citations &amp; Further Reading</h2>

<ul>
  <li><a href="https://agents.md/">AGENTS.md</a> — format, stewardship, and the “README for agents” line.</li>
  <li><a href="https://aaif.io/">Agentic AI Foundation</a> — the foundation named on agents.md.</li>
  <li><a href="https://devops.com/claude-code-adds-agents-md-fallback-cutting-instruction-file-sprawl/">Claude Code adds an AGENTS.md fallback</a> — the 2.1.277 behavior, including the “both files” rule. Corroborated by Classmethod’s 19 Sep 2026 notes.</li>
  <li><a href="https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing">Introducing the Open Knowledge Format</a> — Google Cloud, 12 Jun 2026. McVeety and Hormati.</li>
  <li><a href="https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md">OKF SPEC.md</a> — v0.2 required and optional frontmatter. This outranks the announcement if they differ.</li>
  <li><a href="https://docs.openviking.ai/en/getting-started/01-introduction">OpenViking introduction</a> — context database, <code class="language-plaintext highlighter-rouge">viking://</code>, resources, memories, skills.</li>
  <li><a href="https://docs.openviking.ai/en/concepts/02-context-types">OpenViking context types</a> — memory subtypes and who writes them.</li>
  <li><a href="https://docs.openviking.ai/en/concepts/05-storage">OpenViking storage</a> — AGFS plus a vector index. L0, L1, and L2.</li>
  <li><a href="https://github.com/volcengine/OpenViking">OpenViking repository</a> — where the LoCoMo figures are claimed.</li>
  <li><a href="https://github.com/langchain-ai/langgraph">LangGraph</a> — stateful graphs, cycles, the back edge in the sketch.</li>
  <li><a href="/techtalkwith-veeresh/ai/architecture/web-grounded-chatbot-chrome-on-device-ai-cloudflare-worker/">Web-grounded answers without a server</a> — the client-side chatbot this essay keeps using as the concrete case.</li>
</ul>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="ai" /><category term="architecture" /><category term="agents-md" /><category term="claude-md" /><category term="rag" /><category term="okf" /><category term="openviking" /><category term="langgraph" /><category term="memory" /><summary type="html"><![CDATA[CLAUDE.md is not memory, a vector search is not a decision, and OpenViking is not a nicer RAG. Here is which drawer holds what.]]></summary></entry><entry><title type="html">Web-Grounded Answers Without a Server: What Chrome’s On-Device AI Can (and Can’t) Do</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/web-grounded-chatbot-chrome-on-device-ai-cloudflare-worker/" rel="alternate" type="text/html" title="Web-Grounded Answers Without a Server: What Chrome’s On-Device AI Can (and Can’t) Do" /><published>2026-09-25T00:00:00+00:00</published><updated>2026-09-25T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/web-grounded-chatbot-chrome-on-device-ai-cloudflare-worker</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/ai/architecture/web-grounded-chatbot-chrome-on-device-ai-cloudflare-worker/"><![CDATA[<p>Here’s a question that sounds simple until you actually try to ship it: a chatbot on a static site answers questions from a fixed knowledge base (resume, GitHub repos, blog posts). What happens when a visitor asks something that isn’t in there?</p>

<p>The obvious answer is “let it search the web.” The obvious answer is also where most people get the architecture wrong, because “the browser has an AI built in now” and “the browser can search the web for me” are two very different claims, and only one of them is true.</p>

<p>This post is the write-up of scoping that path for a small portfolio chatbot: what the architecture actually requires, what got built and verified, and what got deliberately left unfinished rather than shipped half-working.</p>

<h2 id="the-assumption-that-doesnt-hold">The Assumption That Doesn’t Hold</h2>

<p>Chrome ships a built-in, on-device language model (Gemini Nano, exposed to the page through <code class="language-plaintext highlighter-rouge">window.LanguageModel</code>), and as of the current <a href="https://developer.chrome.com/docs/ai/prompt-api">Prompt API</a>, that model supports <strong>tool calling</strong>: you can hand it a function definition, and the model decides for itself, mid-conversation, whether calling that function would help answer the question.</p>

<p>What people hear when they read that: “the browser can search the web for me.”</p>

<p>What it actually says: <strong>the model can decide to call a tool you provide.</strong> It does not come with a tool. It has no network access of its own, no search index, no idea what’s happened since its training cutoff. If you want it to search the web, you write the <code class="language-plaintext highlighter-rouge">searchWeb</code> tool yourself and hand it over. The on-device model is the reasoning engine — deciding <em>whether</em> to search, and turning raw results into a readable answer — not the search engine itself.</p>

<h3 id="how-the-handoff-actually-works">How the Handoff Actually Works</h3>

<p>Here’s the mechanism, end to end, for a question the knowledge base can’t answer:</p>

<ol>
  <li><strong>You define the tool.</strong> A name (<code class="language-plaintext highlighter-rouge">searchWeb</code>), a description, and a JSON schema for its input. Chrome’s model reads this the way you’d read an API contract, per the <a href="https://developer.chrome.com/docs/ai/prompt-api">Prompt API</a>’s tool-calling spec.</li>
  <li><strong>The model decides.</strong> It reads the question, checks that against what it actually knows, and decides for itself whether calling <code class="language-plaintext highlighter-rouge">searchWeb</code> would help. That decision is the model’s, not an <code class="language-plaintext highlighter-rouge">if</code> statement you wrote.</li>
  <li><strong>The model calls the tool.</strong> It doesn’t fetch anything itself. It emits a structured call: run <code class="language-plaintext highlighter-rouge">searchWeb</code> with <code class="language-plaintext highlighter-rouge">query: "..."</code>. Your JavaScript is what turns that into an actual HTTP request.</li>
  <li><strong>Execution is the bottleneck.</strong> This is exactly where the credential problem below shows up. Client-side code with no backend has no safe way to call a real search API on its own.</li>
  <li><strong>The model reads the result and answers.</strong> Whatever your tool’s <code class="language-plaintext highlighter-rouge">execute</code> function returns, the model reads as plain text and writes the final answer from it. It never sees a credential — only whatever your tool handed back.</li>
</ol>

<p>Step 3 is free. Step 4 is the entire feature, and it’s why the rest of this post exists.</p>

<p>That distinction is the entire architecture of this feature. Get it right and the client-side piece is maybe 40 lines of code. Get it wrong and you end up trying to smuggle a paid search API’s credentials into a static HTML file, which brings us to the next problem.</p>

<h2 id="why-the-key-cant-just-live-in-the-page">Why the Key Can’t Just Live in the Page</h2>

<p>A chatbot built as 100% static client-side JavaScript (no backend, no server rendering, nothing to patch) is a fine architecture for a resume knowledge base, a live GitHub feed, a blog search. None of those need a secret.</p>

<p>Web search is different. Any real search API requires a key on every request. Any value written into <code class="language-plaintext highlighter-rouge">index.html</code>, however it’s obfuscated, minified, or loaded from a “config” file, ships to the browser and is visible in view-source to every visitor, indexed by anyone who cares to look, and usable by anyone who copies it out. A secret that ships to the client isn’t a secret anymore; it’s a public key with your name on the bill.</p>

<p>So the one piece of this feature that would need a real backend isn’t the AI. It’s whatever holds the credential the AI’s tool call needs.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph Static["Everything else — static client JS, no backend"]
        KB["📄 Resume knowledge base"]
        Repos["🐙 Live GitHub repos"]
        Blog["📰 Blog feed search"]
    end

    subgraph Secret["The one thing that would need a secret"]
        Search["🔍 Web search grounding"]
    end

    KB -.-&gt;|"no credential needed"| Browser1["Runs entirely in the visitor's browser"]
    Repos -.-&gt;|"public API, no key"| Browser1
    Blog -.-&gt;|"static JSON, no key"| Browser1
    Search --&gt;|"needs an API key"| Proxy["⚙️ A single-purpose proxy&lt;br/&gt;(holds the key server-side)"]

    style Search fill:#fee2e2,color:#000
    style Proxy fill:#fef3c7,color:#000
    style Browser1 fill:#dbeafe,color:#000
</code></pre>

<p>Everything on the left runs happily forever with zero backend. The one box on the right is the whole reason a server would enter the picture at all, and its only job would be keeping a credential off the public internet. That’s the shape of the fix, independent of which search provider or which serverless platform ends up behind it.</p>

<h2 id="the-half-that-got-built-tool-calling-on-the-client">The Half That Got Built: Tool Calling on the Client</h2>

<p>The client-side half of this (the on-device model deciding whether to search, and how) doesn’t need a backend to write or to reason about. That’s the part that actually got built and is sitting in git history as a real, syntactically-checked implementation, gated so it changes nothing for any visitor unless someone finishes wiring it up:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">tryWebGroundedAnswer</span><span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">SEARCH_PROXY_URL</span> <span class="o">||</span> <span class="o">!</span><span class="p">(</span><span class="dl">"</span><span class="s2">LanguageModel</span><span class="dl">"</span> <span class="k">in</span> <span class="nb">self</span><span class="p">))</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">availability</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">LanguageModel</span><span class="p">.</span><span class="nx">availability</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">availability</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">unavailable</span><span class="dl">"</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">LanguageModel</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span>
      <span class="na">tools</span><span class="p">:</span> <span class="p">[{</span>
        <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">searchWeb</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">description</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Search the live web for current information not in your training data.</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">inputSchema</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">object</span><span class="dl">"</span><span class="p">,</span> <span class="na">properties</span><span class="p">:</span> <span class="p">{</span> <span class="na">query</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span> <span class="p">}</span> <span class="p">},</span> <span class="na">required</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">query</span><span class="dl">"</span><span class="p">]</span> <span class="p">},</span>
        <span class="na">execute</span><span class="p">:</span> <span class="k">async</span> <span class="p">({</span> <span class="na">query</span><span class="p">:</span> <span class="nx">q</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
          <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">SEARCH_PROXY_URL</span><span class="p">}</span><span class="s2">?q=</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">q</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
          <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">return</span> <span class="dl">"</span><span class="s2">Search unavailable right now.</span><span class="dl">"</span><span class="p">;</span>
          <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
          <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">((</span><span class="nx">data</span><span class="p">.</span><span class="nx">results</span> <span class="o">||</span> <span class="p">[]).</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">title</span><span class="p">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span> <span class="na">url</span><span class="p">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span> <span class="na">snippet</span><span class="p">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">description</span> <span class="p">})));</span>
        <span class="p">}</span>
      <span class="p">}]</span>
    <span class="p">});</span>

    <span class="kd">const</span> <span class="nx">reply</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">session</span><span class="p">.</span><span class="nx">prompt</span><span class="p">(</span>
      <span class="s2">`A visitor asked something outside your normal knowledge base: "</span><span class="p">${</span><span class="nx">query</span><span class="p">}</span><span class="s2">". `</span> <span class="o">+</span>
      <span class="s2">`Use the searchWeb tool if it would help. Answer in 2-3 sentences, plainly, `</span> <span class="o">+</span>
      <span class="s2">`and only state something as fact if the search results actually support it.`</span>
    <span class="p">);</span>
    <span class="nx">session</span><span class="p">.</span><span class="nx">destroy</span><span class="p">?.();</span>
    <span class="k">return</span> <span class="p">(</span><span class="nx">reply</span> <span class="o">||</span> <span class="dl">""</span><span class="p">).</span><span class="nx">trim</span><span class="p">()</span> <span class="o">||</span> <span class="kc">null</span><span class="p">;</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="c1">// unavailable, model not downloaded, tool calling unsupported — fail silent</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here’s the intended flow this is designed to produce, once a real proxy exists behind <code class="language-plaintext highlighter-rouge">SEARCH_PROXY_URL</code>:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant V as Visitor
    participant M as On-device model
    participant P as Search proxy
    V-&gt;&gt;M: Asks something outside the KB
    Note over M: Last-resort path only —&lt;br/&gt;KB/repo/blog all missed
    M-&gt;&gt;M: Decides to call searchWeb
    M-&gt;&gt;P: GET /?q=...
    P--&gt;&gt;M: Trimmed results
    M-&gt;&gt;M: Synthesizes a short answer
    M--&gt;&gt;V: Answer + "used web search" note
</code></pre>

<p>Two gating details matter as much as the happy path:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">SEARCH_PROXY_URL</code> defaults to an empty string.</strong> Until a real proxy is deployed and its URL is pasted in, this function returns <code class="language-plaintext highlighter-rouge">null</code> on its first line, every time. Zero behavior change for every visitor, on every browser. It’s opt-in by construction, not by a feature flag someone has to remember to check.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">'LanguageModel' in self</code> is the whole browser-support check</strong>, and it’s deliberately just a feature-detect, not a browser sniff. Firefox, Safari, and Chrome without on-device AI enabled all just skip straight past it to the existing fallback message. No error, no broken UI, no console noise a visitor would ever see.</li>
</ol>

<h2 id="the-half-that-didnt-ship">The Half That Didn’t Ship</h2>

<p>This is the part worth being honest about rather than glossing over: the proxy side (the actual backend that would hold a search API key and relay queries) was scoped, and a first pass at it was written, but it was never deployed with real credentials and never exercised end-to-end. No live search ever ran through it. Writing up specific vendor deployment steps as lessons learned would be claiming field experience that doesn’t exist yet, so this post doesn’t do that.</p>

<p>What <em>is</em> true, independent of which provider or platform ends up behind the proxy: a credential-holding backend for this feature is the smallest possible surface, a single relay endpoint, no LLM call, no state, no database. That’s a decision worth making deliberately rather than reaching for a heavier pattern (a full RAG pipeline, a vector database, a general-purpose backend) that this specific problem doesn’t need.</p>

<h2 id="the-tradeoff-that-doesnt-go-away">The Tradeoff That Doesn’t Go Away</h2>

<p>Even once the proxy side is finished, this only works on Chrome, only when Chrome’s on-device model is available and downloaded, and it silently does nothing everywhere else.</p>

<p>That’s not a bug to fix later. It’s the actual shape of the constraint. On-device AI is still rolling out, gated behind hardware and storage requirements, and Chrome-only by definition since it’s a Chrome API, not a web standard every browser implements. Building this feature to <em>require</em> it, rather than degrading gracefully in front of it, would mean most visitors get nothing where they currently get a working fallback message. The gating logic isn’t defensive boilerplate; it’s the feature’s actual contract with reality.</p>

<h2 id="why-its-scoped-not-shipped">Why It’s Scoped, Not Shipped</h2>

<p>If you go looking for this in a live chatbot today, you won’t find it wired in. It was scaffolded, then deliberately left unfinished rather than half-deployed. That’s a legitimate outcome, not an abandoned one: merging something inert and hoping to finish it later under different pressure isn’t the same as shipping a feature, and pretending unverified deployment steps are lessons learned would misrepresent work that didn’t happen. The client-side half is complete and sitting in version control as a real starting point: reasoning logic, gating, tool-calling pattern, all of it, for whoever picks it up next.</p>

<h2 id="whats-verified-whats-documented-whats-just-scoped">What’s Verified, What’s Documented, What’s Just Scoped</h2>

<p>Not every claim in this post rests on the same footing, so here’s the breakdown rather than letting it all read as equally certain:</p>

<table>
  <thead>
    <tr>
      <th>Claim</th>
      <th>Confidence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Tool calling lets the on-device model decide when to call a function you define</td>
      <td><strong>Documented</strong> — Chrome’s <a href="https://developer.chrome.com/docs/ai/prompt-api">Prompt API</a> and <a href="https://developer.chrome.com/docs/ai/built-in-apis">built-in AI APIs</a> reference</td>
    </tr>
    <tr>
      <td>The gating logic and tool-calling code shown in this post</td>
      <td><strong>Verified</strong> — syntax-checked, and exercised in headless Chromium against a stubbed <code class="language-plaintext highlighter-rouge">LanguageModel</code> to confirm the code path itself is reachable</td>
    </tr>
    <tr>
      <td>The search-proxy backend’s architecture (single relay endpoint, no LLM call, no state)</td>
      <td><strong>Scoped only</strong> — a first pass was written but never deployed with real credentials or exercised end-to-end</td>
    </tr>
    <tr>
      <td>Real on-device model behavior (actual tool invocation, answer quality, latency)</td>
      <td><strong>Unverified here</strong> — this sandboxed environment has no access to Chrome’s real on-device model, only a stub</td>
    </tr>
  </tbody>
</table>

<p>The gap between “verified” and “unverified” isn’t a footnote. It’s the entire reason this post reports what got scoped instead of writing up deployment steps for a backend that never ran.</p>

<h2 id="key-takeaways">Key Takeaways</h2>

<ol>
  <li><strong>Tool calling gives a model the ability to decide to use a tool. It doesn’t hand the model the tool itself.</strong> Every capability the model “has” through tool calling is one you built and handed over.</li>
  <li><strong>A secret in client-side JavaScript isn’t hidden, it’s published.</strong> The only fix is moving the secret behind something that isn’t shipped to the browser.</li>
  <li><strong>The reasoning can live entirely on the client even when a secret can’t.</strong> Splitting “who decides” from “who holds the credential” keeps the client-side half genuinely backend-free, even before the backend half exists.</li>
  <li><strong>Fail-silent gating is a feature, not a shortcut.</strong> An empty proxy URL by default and a plain <code class="language-plaintext highlighter-rouge">'LanguageModel' in self</code> check mean unsupported browsers (and an unfinished backend) see nothing different, ever.</li>
  <li><strong>Scoping a feature and stopping before the unverified part is a legitimate outcome.</strong> Writing up deployment lessons you haven’t actually lived through is worse than not writing them up at all.</li>
</ol>

<h2 id="citations--further-reading">Citations &amp; Further Reading</h2>

<ul>
  <li>Chrome’s built-in AI and the Prompt API — <a href="https://developer.chrome.com/docs/ai/prompt-api">developer.chrome.com/docs/ai/prompt-api</a></li>
  <li>Chrome’s built-in AI APIs overview (<code class="language-plaintext highlighter-rouge">window.LanguageModel</code> availability, tool calling) — <a href="https://developer.chrome.com/docs/ai/built-in-apis">developer.chrome.com/docs/ai/built-in-apis</a></li>
</ul>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="ai" /><category term="architecture" /><category term="chrome-ai" /><category term="gemini-nano" /><category term="prompt-api" /><category term="tool-calling" /><category term="rag" /><category term="on-device-ai" /><category term="security" /><summary type="html"><![CDATA[A portfolio chatbot needed to answer questions outside its own knowledge base without shipping a search API key to every visitor. Scoping that feature surfaces a real architectural lesson about where secrets are allowed to live, even before a single line of it goes to production.]]></summary></entry><entry><title type="html">Self-Hosting an AI Chatbot on a $150 Raspberry Pi 5</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/devops/automation/self-hosted-ai-chatbot-raspberry-pi-5/" rel="alternate" type="text/html" title="Self-Hosting an AI Chatbot on a $150 Raspberry Pi 5" /><published>2026-08-30T00:00:00+00:00</published><updated>2026-08-30T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/devops/automation/self-hosted-ai-chatbot-raspberry-pi-5</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/devops/automation/self-hosted-ai-chatbot-raspberry-pi-5/"><![CDATA[<p>Running an AI chatbot usually means a cloud bill that never stops. AWS, Azure, GCP — pick one, and it’s $40-100/month for a workload that, honestly, isn’t doing that much. So we asked the obvious question: what if we just didn’t pay it?</p>

<p>This post walks through deploying a production AI chatbot and website entirely on a Raspberry Pi 5, a $150 computer about the size of a deck of cards. Your data stays on your hardware, your running cost drops to whatever electricity the Pi draws (about $20/year), and no cloud vendor has any say in your code or your bill.</p>

<p>We built it with AI-assisted coding agents doing most of the legwork — Claude Code writing the backend and frontend, a review pass catching security issues, a deployment pass generating the Nginx config and PM2 scripts. More on that further down. By the end of this you’ll have a React chatbot talking to a local AI engine (Ollama), a SQLite database quietly keeping chat history, Nginx routing traffic, and PM2 making sure the whole thing survives a crash or a reboot. No DevOps background required — just copy-paste and about 90 minutes.</p>

<h2 id="the-architecture">The Architecture</h2>

<p>Picture a small relay team, each runner with exactly one job:</p>

<pre><code class="language-mermaid">graph TB
    User["🌐 You&lt;br/&gt;(Your Browser)"]
    Nginx["⚙️ Nginx&lt;br/&gt;(Traffic Cop)"]
    Backend["🔧 Node.js&lt;br/&gt;(Backend)"]
    React["⚡ React&lt;br/&gt;(Frontend)"]
    Ollama["🤖 Ollama&lt;br/&gt;(AI Engine)"]
    Model["🧠 Phi3:Mini&lt;br/&gt;(The Model)"]
    SQLite["📦 SQLite&lt;br/&gt;(Chat History)"]
    Filesystem["💾 Pi Filesystem&lt;br/&gt;(USB SSD)"]

    User --&gt;|"Ask something"| Nginx
    Nginx --&gt;|"Route to API"| Backend
    Nginx --&gt;|"Serve page"| React
    Backend --&gt;|"Send query"| Ollama
    Ollama --&gt;|"Run inference"| Model
    Backend --&gt;|"Save chat"| SQLite
    SQLite --&gt;|"Persist"| Filesystem
    React --&gt;|"Show answer"| User

    style User fill:#e1f5ff,color:#000
    style Nginx fill:#fff3e0,color:#000
    style Backend fill:#f3e5f5,color:#000
    style React fill:#e8f5e9,color:#000
    style Ollama fill:#fce4ec,color:#000
    style Model fill:#fce4ec,color:#000
    style SQLite fill:#ede7f6,color:#000
    style Filesystem fill:#e0f2f1,color:#000
</code></pre>

<p>Trace it left to right: you open your browser and hit the Pi’s address. Nginx looks at the request and decides where it goes — static files straight to the pre-built React frontend, API calls forwarded to the Node.js backend. Type a message into the chatbot and it lands on the backend, which hands the question to Ollama running the Phi3:mini model locally. That takes 5-10 seconds on the Pi’s own CPU, no cloud involved. The response comes back, gets logged to SQLite for chat history, and the whole exchange never leaves your network.</p>

<p>One thing that diagram doesn’t show: your very first message behaves nothing like every message after it, because Ollama has to load the model into RAM before it can answer anything.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant U as You (Browser)
    participant N as Nginx
    participant B as Backend (Node)
    participant O as Ollama
    Note over U,O: First message since boot/restart — cold start
    U-&gt;&gt;N: POST /api/chat
    N-&gt;&gt;B: proxy request
    B-&gt;&gt;O: run inference
    Note right of O: Loading Phi3:mini into RAM&lt;br/&gt;30-45s, only happens once
    O--&gt;&gt;B: response
    B--&gt;&gt;U: chat reply
    Note over U,O: Every message after that — warm
    U-&gt;&gt;N: POST /api/chat
    N-&gt;&gt;B: proxy request
    B-&gt;&gt;O: run inference
    Note right of O: Model already in RAM&lt;br/&gt;5-10s
    O--&gt;&gt;B: response
    B--&gt;&gt;U: chat reply
</code></pre>

<p>That first 30-45 second wait (covered again in Part 4) is the single most common “is this broken?” moment for people trying this for the first time. It isn’t broken — it only happens once per reboot.</p>

<h2 id="the-tech-stack">The Tech Stack</h2>

<h3 id="frontend-react--vite">Frontend: React + Vite</h3>
<p>A React single-page app, pre-built into static HTML/JS. React keeps the UI responsive without page reloads; Vite builds it in about 2 seconds where Webpack would take 30. The built files land in <code class="language-plaintext highlighter-rouge">/dist</code> and Nginx just serves them straight, no runtime overhead.</p>

<h3 id="backend-nodejs--express">Backend: Node.js + Express</h3>
<p>A lightweight REST API handling chatbot requests and database reads/writes. Node’s non-blocking I/O means one small process can hold thousands of connections, so a Pi running 300MB of Node comfortably out-handles a cloud box running something heavier. Express stays minimal, no magic, <code class="language-plaintext highlighter-rouge">app.get('/api/chat', handler)</code> and it works. PM2 manages the process at runtime.</p>

<h3 id="process-manager-pm2">Process manager: PM2</h3>
<p>Keeps the Node server running around the clock, restarts it if it crashes, rotates logs so you don’t fill the disk. <code class="language-plaintext highlighter-rouge">pm2 logs my-app</code> gets you everything in one line. We picked it over raw systemd because it hands you process monitoring and graceful restarts without writing shell scripts yourself.</p>

<h3 id="web-server-nginx">Web server: Nginx</h3>
<p>The public-facing gateway: routes static files to React, proxies API calls to the Node backend, handles HTTPS if you set it up. It runs on roughly 5MB of RAM using a single event loop; Apache can burn 50MB per connection, and on a Pi that difference is real.</p>

<h3 id="ai-engine-ollama">AI engine: Ollama</h3>
<p>Runs open-source language models natively on the Pi’s own CPU. No cloud dependency, no API keys, no rate limits: your model, your data, your hardware. Routing a local deployment through someone else’s cloud API would defeat the point, and Ollama is free besides. We landed on Phi3:mini: 2GB, roughly 5-10 seconds per response on a Pi 5, small enough to sit comfortably in RAM and fast enough to feel close to real-time.</p>

<h3 id="database-sqlite">Database: SQLite</h3>
<p>Chat history in a single self-contained file. No server to manage, no connection-pool headaches, and backing up means copying one file. It lives at <code class="language-plaintext highlighter-rouge">./data/chat_logs.db</code> on the Pi’s filesystem.</p>

<h3 id="hardware-raspberry-pi-5-8gb">Hardware: Raspberry Pi 5 (8GB)</h3>
<p>The quad-core ARM Cortex-A76 handles Node and local inference without strain, and 8GB RAM comfortably covers Ollama (~3GB) + Node (~300MB) + Nginx (~5MB) + the OS (~1GB) with room to spare. All in: about $150 in hardware and roughly $20/year in electricity at 15W, against something like $40/month on AWS for equivalent compute, which works out to $480/year. A passive heatsink or fan case is worth the $10, since the Pi 5 does run warm under sustained load.</p>

<h2 id="built-with-ai-coding-agents">Built with AI Coding Agents</h2>

<p>The traditional path to something like this: hire a fullstack developer, two weeks on architecture, six weeks writing and testing, a week of review, a week chasing deployment issues. Call it $15K in salary plus the overhead of getting everyone’s calendar to line up.</p>

<p>Here’s the version we actually ran, spread over four weeks of part-time review rather than full-time building:</p>

<h3 id="week-1-spec">Week 1: Spec</h3>
<p>A two-page spec (“React frontend, Node backend, Ollama for AI”) fed to Claude Code’s planning agents came back as a full architecture, file structure, and component list. Reviewing that plan took an hour instead of two weeks.</p>

<h3 id="week-2-implementation">Week 2: Implementation</h3>
<p>Agents wrote the React components and the Express backend, with a review pass catching bugs before anything got merged. By the time code landed on main, it was already tested.</p>

<h3 id="week-3-deployment">Week 3: Deployment</h3>
<p>A deployment pass generated the Nginx config, PM2 scripts, and setup shell commands. Copy-paste onto the Pi, and it worked.</p>

<h3 id="week-4-hardening">Week 4: Hardening</h3>
<p>A security pass scanned for the usual OWASP suspects, SQL injection, XSS, CSRF, found three real bugs, and they were fixed the same afternoon before the chatbot went live.</p>

<p>All told: about $5K in API credits against $15K+ in developer salary, plus our own time reviewing what the agents produced. The upside isn’t just the price tag. Every command in this post has actually been run, on real hardware, by the same pipeline that wrote it, which is why nothing here should surprise you mid-setup.</p>

<h2 id="part-1-hardware--os-setup-30-minutes">Part 1: Hardware &amp; OS Setup (30 minutes)</h2>

<p><strong>What you’ll need:</strong></p>
<ul>
  <li>Raspberry Pi 5 with 8GB RAM (Ollama needs the headroom)</li>
  <li>USB SSD (1TB is comfortable, 256GB works fine, a microSD works too, just slower)</li>
  <li>A heatsink or passive fan case, since the Pi 5 throttles when it gets hot</li>
  <li>The official 27W USB-C power adapter, not a generic one</li>
  <li>Your laptop (Windows, Mac, or Linux, doesn’t matter for SSH)</li>
</ul>

<h3 id="step-1-flash-the-os">Step 1: Flash the OS</h3>

<ol>
  <li>Download <strong>Raspberry Pi Imager</strong> from https://www.raspberrypi.com/software/</li>
  <li>Plug your USB SSD into your laptop</li>
  <li>In Imager, choose:
    <ul>
      <li><strong>Device:</strong> Raspberry Pi 5</li>
      <li><strong>OS:</strong> Raspberry Pi OS (64-bit, Lite, no GUI, which leaves more RAM for Ollama)</li>
      <li><strong>Storage:</strong> your USB SSD</li>
    </ul>
  </li>
  <li>Open <strong>Advanced Options</strong> (the gear icon) and set:
    <ul>
      <li><strong>Hostname:</strong> <code class="language-plaintext highlighter-rouge">my-pi</code> (or whatever you like, just remember it)</li>
      <li><strong>Enable SSH:</strong> yes, with password auth</li>
      <li><strong>Username:</strong> <code class="language-plaintext highlighter-rouge">piuser</code></li>
      <li><strong>Password:</strong> something you won’t forget, you’ll type it once</li>
      <li><strong>WiFi:</strong> your home network’s SSID and password</li>
    </ul>
  </li>
  <li>Click <strong>Write</strong> and let it run (~5 minutes)</li>
</ol>

<h3 id="step-2-boot--ssh-in">Step 2: Boot &amp; SSH In</h3>

<ol>
  <li>Plug the SSD into the Pi’s USB 3.0 (blue) slot, then power it on</li>
  <li>Wait about 90 seconds for first boot</li>
  <li>On your laptop, open PowerShell (Windows) or Terminal (Mac/Linux) and try:
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh piuser@my-pi.local
</code></pre></div>    </div>
    <p>If that connects, you’re in. Use the password from Step 1.</p>
  </li>
  <li>If mDNS is being fussy and that fails:
    <ul>
      <li>Log into your router’s admin page (usually <code class="language-plaintext highlighter-rouge">192.168.1.1</code>)</li>
      <li>Find “Connected Devices” and look for <code class="language-plaintext highlighter-rouge">my-pi</code></li>
      <li>Note its IP (something like <code class="language-plaintext highlighter-rouge">192.168.1.42</code>), then:
        <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh piuser@192.168.1.42
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>You’re in when you see <code class="language-plaintext highlighter-rouge">piuser@my-pi:~ $</code>.</li>
</ol>

<h2 id="part-2-install-the-software-10-minutes">Part 2: Install the Software (10 minutes)</h2>

<p>Run these on the Pi, one at a time so you can see what’s working:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Update system packages</span>
<span class="nb">sudo </span>apt update <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>apt upgrade <span class="nt">-y</span>

<span class="c"># Install Node.js v20 (required for modern JavaScript)</span>
curl <span class="nt">-fsSL</span> https://deb.nodesource.com/setup_20.x | <span class="nb">sudo</span> <span class="nt">-E</span> bash -
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> nodejs

<span class="c"># Install PM2 globally (keeps your app running forever)</span>
<span class="nb">sudo </span>npm <span class="nb">install</span> <span class="nt">-g</span> pm2

<span class="c"># Install Nginx (web server)</span>
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> nginx
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>nginx

<span class="c"># Install Ollama (AI engine)</span>
curl <span class="nt">-fsSL</span> https://ollama.com/install.sh | sh

<span class="c"># Download the AI model (Phi3:mini, 2GB)</span>
<span class="c"># This takes a few minutes on a decent WiFi connection</span>
ollama pull phi3:mini

<span class="c"># Verify everything installed</span>
node <span class="nt">-v</span> <span class="o">&amp;&amp;</span> npm <span class="nt">-v</span> <span class="o">&amp;&amp;</span> pm2 <span class="nt">-v</span> <span class="o">&amp;&amp;</span> nginx <span class="nt">-v</span> <span class="o">&amp;&amp;</span> ollama <span class="nt">-v</span>
</code></pre></div></div>

<p>Five version numbers back, and you’re set. If one install fails, re-run that step; usually just a dropped connection, not a real problem.</p>

<h2 id="part-3-copy-your-code--configure-15-minutes">Part 3: Copy Your Code &amp; Configure (15 minutes)</h2>

<p><strong>A note on the code:</strong> this part assumes you already have a working React + Node app on your laptop to copy over — this guide covers <em>deploying</em> one, not writing one from scratch. If you’re starting from zero, scaffold a minimal version first and get it talking to Ollama on your own laptop before you touch the Pi (much easier to debug there than over SSH):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Frontend: a bare React app</span>
npm create vite@latest web-run <span class="nt">--</span> <span class="nt">--template</span> react
<span class="nb">cd </span>web-run <span class="o">&amp;&amp;</span> npm <span class="nb">install</span>

<span class="c"># Backend: a bare Express app with one route</span>
npm <span class="nb">install </span>express
</code></pre></div></div>

<p>A minimal <code class="language-plaintext highlighter-rouge">server.js</code> just needs one route that forwards a prompt to Ollama’s local HTTP API and returns the reply — <code class="language-plaintext highlighter-rouge">POST http://127.0.0.1:11434/api/generate</code> with <code class="language-plaintext highlighter-rouge">{ "model": "phi3:mini", "prompt": "...", "stream": false }</code> is the whole contract. Once that round-trips on your laptop, the rest of this post is about getting that same app running reliably on the Pi.</p>

<h3 id="from-your-laptop">From your laptop</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Navigate to your project</span>
<span class="nb">cd</span> /path/to/your/webapp

<span class="c"># Copy code to the Pi (replace 192.168.1.X with your Pi's actual IP from Part 1)</span>
scp <span class="nt">-r</span> server.js package.json index.html vite.config.js tailwind.config.js eslint.config.js src public piuser@192.168.1.X:~/web-run/

<span class="c"># Install dependencies on the Pi</span>
ssh piuser@192.168.1.X <span class="s2">"cd ~/web-run &amp;&amp; npm install"</span>
</code></pre></div></div>

<h3 id="on-the-pi-ssh-terminal">On the Pi (SSH terminal)</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/web-run

<span class="c"># Create the environment file</span>
nano .env
</code></pre></div></div>

<p>Paste this in, swapping <code class="language-plaintext highlighter-rouge">192.168.1.X</code> for your Pi’s actual IP:</p>
<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">NODE_ENV</span><span class="p">=</span><span class="s">production</span>
<span class="py">PORT</span><span class="p">=</span><span class="s">3001</span>
<span class="py">FRONTEND_URL</span><span class="p">=</span><span class="s">http://192.168.1.X</span>

<span class="c"># Ollama must use 127.0.0.1 (localhost) — not 'ollama'
</span><span class="py">OLLAMA_HOST</span><span class="p">=</span><span class="s">http://127.0.0.1:11434</span>
<span class="py">AI_MODEL</span><span class="p">=</span><span class="s">phi3:mini</span>
<span class="py">AI_TEMPERATURE</span><span class="p">=</span><span class="s">0.7</span>
<span class="py">AI_MAX_TOKENS</span><span class="p">=</span><span class="s">250</span>
<span class="py">AI_CTX_WINDOW</span><span class="p">=</span><span class="s">8192</span>

<span class="c"># Database (auto-created)
</span><span class="py">DB_PATH</span><span class="p">=</span><span class="s">./data/chat_logs.db</span>

<span class="c"># Optional: push notifications via ntfy.sh — see Part 4
</span><span class="py">PUSH_NOTIFICATIONS</span><span class="p">=</span><span class="s">false</span>
<span class="py">NTFY_TOPIC</span><span class="p">=</span>
</code></pre></div></div>

<p>Save with <code class="language-plaintext highlighter-rouge">Ctrl+X</code> → <code class="language-plaintext highlighter-rouge">Y</code> → <code class="language-plaintext highlighter-rouge">Enter</code>. (Nano can be finicky about pasting; if it garbles, type it in slowly or line by line.)</p>

<h2 id="part-4-start-everything-10-minutes">Part 4: Start Everything (10 minutes)</h2>

<h3 id="build-the-frontend">Build the frontend</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/web-run
npm run build
<span class="c"># Generates ./dist with static HTML/JS/CSS, about 30 seconds</span>
</code></pre></div></div>

<h3 id="start-the-backend-with-pm2">Start the backend with PM2</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pm2 start server.js <span class="nt">--name</span> my-app
pm2 save
pm2 startup
<span class="c"># Copy the command 'pm2 startup' prints and run it — one-time setup</span>
<span class="c"># so PM2 survives a reboot</span>
</code></pre></div></div>

<h3 id="configure-nginx">Configure Nginx</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>nano /etc/nginx/sites-available/my-app
</code></pre></div></div>

<p>Paste this in, swapping <code class="language-plaintext highlighter-rouge">192.168.1.X</code> for your Pi’s IP:</p>
<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">server</span> <span class="p">{</span>
    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
    <span class="kn">server_name</span> <span class="mi">192</span><span class="s">.168.1.X</span><span class="p">;</span>  <span class="c1"># Your Pi's IP</span>

    <span class="kn">root</span> <span class="n">/home/piuser/web-run/dist</span><span class="p">;</span>
    <span class="kn">index</span> <span class="s">index.html</span><span class="p">;</span>

    <span class="c1"># Security headers</span>
    <span class="kn">add_header</span> <span class="s">X-Content-Type-Options</span> <span class="s">"nosniff"</span> <span class="s">always</span><span class="p">;</span>
    <span class="kn">add_header</span> <span class="s">X-Frame-Options</span> <span class="s">"DENY"</span> <span class="s">always</span><span class="p">;</span>
    <span class="kn">add_header</span> <span class="s">Referrer-Policy</span> <span class="s">"strict-origin-when-cross-origin"</span> <span class="s">always</span><span class="p">;</span>

    <span class="c1"># API calls → Node.js backend</span>
    <span class="kn">location</span> <span class="n">/api/</span> <span class="p">{</span>
        <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:3001</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
        <span class="kn">proxy_read_timeout</span> <span class="s">120s</span><span class="p">;</span>  <span class="c1"># Ollama might be slow; give it time</span>
    <span class="p">}</span>

    <span class="c1"># React SPA fallback</span>
    <span class="kn">location</span> <span class="n">/</span> <span class="p">{</span>
        <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri</span><span class="n">/</span> <span class="n">/index.html</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Enable it:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo rm</span> /etc/nginx/sites-enabled/default  <span class="c"># Remove default config</span>
<span class="nb">sudo ln</span> <span class="nt">-s</span> /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/

<span class="c"># Fix file permissions (this one trips people up)</span>
<span class="nb">chmod </span>o+x /home/piuser
<span class="nb">chmod</span> <span class="nt">-R</span> o+rX /home/piuser/web-run/dist

<span class="c"># Test &amp; restart</span>
<span class="nb">sudo </span>nginx <span class="nt">-t</span>
<span class="nb">sudo </span>systemctl restart nginx
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">sudo nginx -t</code> says “syntax is ok,” you’re good.</p>

<h3 id="test-it">Test it</h3>

<p>Open a browser on the same WiFi as the Pi and go to:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://192.168.1.X
</code></pre></div></div>

<p>You should see the React app load. Click the chatbot and say hello.</p>

<p>The first message takes 30-45 seconds while Ollama loads the model into RAM. That’s normal, not a bug. After that, responses land in 5-10 seconds. If the delay doesn’t clear up after the first message, check <code class="language-plaintext highlighter-rouge">pm2 logs my-app</code> for errors around Ollama or model loading.</p>

<p>Your self-hosted AI chatbot is live.</p>

<h3 id="optional-push-notifications-with-ntfysh">Optional: Push Notifications with ntfy.sh</h3>

<p>The <code class="language-plaintext highlighter-rouge">.env</code> file above has a <code class="language-plaintext highlighter-rouge">PUSH_NOTIFICATIONS</code> flag but nothing wires it up yet — here’s the missing piece. <a href="https://ntfy.sh/">ntfy.sh</a> is a free, open-source pub-sub notification service: your backend POSTs a message to a topic URL, and anyone subscribed (the ntfy phone app, or just a browser tab) gets it instantly. No account, no API key, and no extra service to host — it’s a plain HTTPS POST.</p>

<p>Pick a topic name only you know — topic names are the entire access control on the free tier, so treat one like a lightweight secret — and set it in <code class="language-plaintext highlighter-rouge">.env</code>:</p>
<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">PUSH_NOTIFICATIONS</span><span class="p">=</span><span class="s">true</span>
<span class="py">NTFY_TOPIC</span><span class="p">=</span><span class="s">veer-portfolio</span>
</code></pre></div></div>

<p>Then in the Express route that handles chat messages, fire a notification alongside the existing SQLite write:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// After saving the chat exchange to SQLite</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PUSH_NOTIFICATIONS</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NTFY_TOPIC</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">fetch</span><span class="p">(</span><span class="s2">`https://ntfy.sh/</span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NTFY_TOPIC</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">body</span><span class="p">:</span> <span class="s2">`New chat message: "</span><span class="p">${</span><span class="nx">userMessage</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">)}</span><span class="s2">"`</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="na">Title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Chatbot activity</span><span class="dl">"</span><span class="p">,</span> <span class="na">Tags</span><span class="p">:</span> <span class="dl">"</span><span class="s2">speech_balloon</span><span class="dl">"</span> <span class="p">}</span>
  <span class="p">}).</span><span class="k">catch</span><span class="p">(</span><span class="nx">err</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">ntfy notify failed:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">err</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Subscribe to your topic in the ntfy app (iOS/Android) or by opening <code class="language-plaintext highlighter-rouge">https://ntfy.sh/veer-portfolio</code> in a browser, and you’ll get a push the moment someone talks to your chatbot — a handy “is anyone actually using this” pulse check. The <code class="language-plaintext highlighter-rouge">.catch()</code> matters: if ntfy.sh is ever unreachable, that should never be the reason a visitor’s chat response fails to come back.</p>

<h2 id="part-5-public-domain-via-cloudflare-tunnel-optional">Part 5: Public Domain via Cloudflare Tunnel (Optional)</h2>

<p>Want the Pi reachable from your phone when you’re not home, or a real domain instead of a raw IP? Cloudflare Tunnels get you there without opening a port on your router: no port-forwarding, no dynamic-DNS juggling, and HTTPS comes free.</p>

<ol>
  <li>Point your domain at Cloudflare’s nameservers from your registrar (DNS propagation takes about 15 minutes).</li>
  <li>In the Cloudflare dashboard, go to <strong>Zero Trust</strong> → <strong>Tunnels</strong>, click <strong>Create a tunnel</strong>, choose the <strong>Cloudflared</strong> connector, and name it something like <code class="language-plaintext highlighter-rouge">my-pi-tunnel</code>.</li>
  <li>On the Pi, install <code class="language-plaintext highlighter-rouge">cloudflared</code> for ARM64:
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-L</span> <span class="nt">--output</span> cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb
<span class="nb">sudo </span>dpkg <span class="nt">-i</span> cloudflared.deb
</code></pre></div>    </div>
  </li>
  <li>Cloudflare’s dashboard gives you an install command with a token baked in:
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>cloudflared service <span class="nb">install</span> &lt;YOUR_TOKEN&gt;
</code></pre></div>    </div>
  </li>
  <li>Back in the dashboard, under <strong>Public Hostname</strong>, add one: pick a subdomain, your domain, type <strong>HTTP</strong>, and point the URL at <code class="language-plaintext highlighter-rouge">localhost:80</code> (that’s Nginx, listening locally).</li>
  <li>Visit <code class="language-plaintext highlighter-rouge">https://your-subdomain.yourdomain.com</code>. If it doesn’t resolve right away, give it ten minutes. Cloudflare emails you when the tunnel’s live.</li>
</ol>

<h2 id="part-6-maintenance">Part 6: Maintenance</h2>

<p><strong>Backend logs:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pm2 logs my-app
</code></pre></div></div>
<p>Errors show up here first. <code class="language-plaintext highlighter-rouge">Ctrl+C</code> to exit.</p>

<p><strong>Web traffic:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo tail</span> <span class="nt">-f</span> /var/log/nginx/access.log
</code></pre></div></div>

<p><strong>Updating code:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># On your laptop:</span>
scp <span class="nt">-r</span> src server.js package.json piuser@192.168.1.X:~/web-run/

<span class="c"># On the Pi:</span>
<span class="nb">cd</span> ~/web-run
npm run build <span class="o">&amp;&amp;</span> pm2 restart my-app   <span class="c"># rebuild frontend, restart backend</span>
</code></pre></div></div>

<p><strong>Resource monitoring:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>htop   <span class="c"># real-time CPU/RAM — Ollama ~3GB, Node ~300MB, Nginx barely registers</span>
<span class="nb">df</span> <span class="nt">-h</span>  <span class="c"># disk space — clean up before you hit 90%</span>
</code></pre></div></div>

<p><strong>A quick weekly check:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pm2 logs my-app | <span class="nb">tail</span> <span class="nt">-20</span>
<span class="nb">df</span> <span class="nt">-h</span>
curl http://192.168.1.X/api/health
</code></pre></div></div>
<p>If those three come back clean, you’re solid for another week.</p>

<h2 id="troubleshooting">Troubleshooting</h2>

<p><strong>Website won’t load</strong>
→ Is Nginx running? <code class="language-plaintext highlighter-rouge">sudo systemctl status nginx</code>
→ Check the error log: <code class="language-plaintext highlighter-rouge">sudo tail -50 /var/log/nginx/error.log</code>
→ Config typo? <code class="language-plaintext highlighter-rouge">sudo nginx -t</code> will tell you.</p>

<p><strong>Chatbot is slow on every message, not just the first</strong>
→ The Pi might be thermal-throttling. Check with <code class="language-plaintext highlighter-rouge">vcgencmd measure_temp</code>; above 80°C, add cooling.</p>

<p><strong>Backend keeps crashing</strong>
→ <code class="language-plaintext highlighter-rouge">pm2 logs my-app</code> and look for the actual error:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Cannot find module 'express'</code> → run <code class="language-plaintext highlighter-rouge">npm install</code> again</li>
  <li><code class="language-plaintext highlighter-rouge">EADDRINUSE :::3001</code> → something else has the port; <code class="language-plaintext highlighter-rouge">sudo lsof -i :3001</code> and kill it</li>
  <li><code class="language-plaintext highlighter-rouge">Error connecting to Ollama</code> → check <code class="language-plaintext highlighter-rouge">ps aux | grep ollama</code>; if it’s not running, <code class="language-plaintext highlighter-rouge">ollama serve</code></li>
</ul>

<p><strong>403 Forbidden on the website</strong>
→ Nginx can’t read your files. Re-run the <code class="language-plaintext highlighter-rouge">chmod</code> commands from Part 4.</p>

<p><strong>Running low on disk</strong>
→ <code class="language-plaintext highlighter-rouge">df -h</code> first. Usual culprits: old PM2 logs (<code class="language-plaintext highlighter-rouge">pm2 kill</code> clears them), a growing chat-log database, or old Ollama models (<code class="language-plaintext highlighter-rouge">ollama rm phi3:mini</code> frees 2GB). Clean up around 85%, don’t wait for 100%.</p>

<p><strong>Domain not resolving after Cloudflare setup</strong>
→ DNS propagation takes up to 15 minutes. If it’s been longer, <code class="language-plaintext highlighter-rouge">sudo systemctl restart cloudflared</code>.</p>

<p><strong>Nothing above worked</strong>
→ Restart the stack in order: <code class="language-plaintext highlighter-rouge">sudo systemctl restart nginx</code>, <code class="language-plaintext highlighter-rouge">pm2 restart my-app</code>, <code class="language-plaintext highlighter-rouge">sudo systemctl restart cloudflared</code> if you’re using it. Then check <code class="language-plaintext highlighter-rouge">pm2 list</code> and <code class="language-plaintext highlighter-rouge">sudo systemctl status nginx</code>. This clears most weirdness. If it doesn’t, the exact error text from <code class="language-plaintext highlighter-rouge">pm2 logs my-app</code> or the Nginx error log is usually enough to search up an answer.</p>

<h2 id="key-takeaways">Key Takeaways</h2>

<ol>
  <li><strong>Self-hosting is genuinely cheap.</strong> $150 in hardware plus ~$20/year in electricity beats $480+/year in equivalent cloud hosting.</li>
  <li><strong>Ollama does the heavy lifting.</strong> Any open-source LLM, running locally, no API keys, no rate limits.</li>
  <li><strong>The boring parts matter.</strong> PM2 keeps the app alive, Nginx keeps it fast, SQLite keeps it simple.</li>
  <li><strong>AI agents compressed the timeline</strong>, not just the code. Architecture, security review, and deployment scripting all happened before a human ran a single command.</li>
  <li><strong>Your data stays yours.</strong> No cloud, no vendor lock-in, nothing phoning home.</li>
</ol>

<h2 id="whats-next">What’s Next</h2>

<p><strong>This week:</strong> watch it for 72 hours (<code class="language-plaintext highlighter-rouge">pm2 logs my-app</code>, <code class="language-plaintext highlighter-rouge">htop</code>) and get a friend to hit it from a different network to confirm it actually works from the outside. If you care about chat history, copy <code class="language-plaintext highlighter-rouge">chat_logs.db</code> somewhere safe.</p>

<p><strong>This month:</strong> custom styling if you want it to feel like yours, and a look at <code class="language-plaintext highlighter-rouge">df -h</code>/<code class="language-plaintext highlighter-rouge">htop</code> after a few weeks of real traffic to see where it settles.</p>

<p><strong>When you’re ready to push further:</strong> swap Phi3:mini for something bigger (<code class="language-plaintext highlighter-rouge">ollama pull mistral</code>) if you want more capability and can spare the RAM, script a weekly backup of the SQLite file, or add Redis caching for frequent queries.</p>

<hr />

<p>Built with AI-assisted coding agents: Claude Code (Anthropic) handled architecture, code generation, and testing, with multi-agent orchestration patterns inspired by Google’s Antigravity framework. Enjoy your self-hosted AI webapp.</p>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<p>The dollar figures in this post ($150 hardware, about $20 a year of electricity) are my estimate from the build, not a price printed by the vendors.</p>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Raspberry_Pi">Raspberry Pi</a> — the board this build uses. The vendor site blocks the link checker, so this is the stable reference.</li>
  <li><a href="https://ollama.com/">Ollama</a> — the local model runner.</li>
  <li><a href="https://nginx.org/en/docs/">Nginx</a> — the reverse proxy in front of the app.</li>
  <li><a href="https://pm2.keymetrics.io/docs/usage/quick-start/">PM2</a> — what keeps the Node process up.</li>
  <li><a href="https://www.sqlite.org/docs.html">SQLite</a> — the chat log.</li>
  <li><a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/">Cloudflare Tunnel</a> — how the Pi is reached without opening a port.</li>
</ul>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="devops" /><category term="automation" /><category term="raspberry-pi" /><category term="self-hosted" /><category term="ollama" /><category term="llm" /><category term="nginx" /><category term="pm2" /><category term="sqlite" /><category term="ai-agents" /><category term="devops" /><summary type="html"><![CDATA[No cloud bill, no vendor lock-in, no rate limits, just Nginx, PM2, and a local LLM running 24/7 on a computer the size of a deck of cards. Here's the full build, start to finish.]]></summary></entry><entry><title type="html">Self-Healing Test Suites: AI-Powered Locator Healing in CI/CD</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/self-healing-test-suites/" rel="alternate" type="text/html" title="Self-Healing Test Suites: AI-Powered Locator Healing in CI/CD" /><published>2026-07-18T00:00:00+00:00</published><updated>2026-07-18T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/self-healing-test-suites</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/self-healing-test-suites/"><![CDATA[<p>Every QA engineer knows the feeling. You open Slack on Monday morning and see it: <strong>“Build #847 failed — 23 tests, 19 failures.”</strong> The cause? A frontend developer renamed <code class="language-plaintext highlighter-rouge">btn-submit</code> to <code class="language-plaintext highlighter-rouge">submit-button-primary</code> on Friday at 4:59 PM. Nineteen tests. All broken. All for the same CSS class change.</p>

<p>This is the <strong>locator crisis</strong> — the single biggest source of flaky tests in every Selenium and Playwright suite I’ve ever maintained. And as of 2026, we finally have a solution that doesn’t involve begging frontend teams to stop renaming things.</p>

<p>This post shows you how to build <strong>self-healing test suites</strong> — tests that automatically find elements by their semantic role when traditional selectors break, using Java (primary), C#, TypeScript, JavaScript, and Python.</p>

<p>If you read the <a href="/techtalkwith-veeresh/automation/tools/selenium-page-locator-strategies/">Selenium Locators guide from 2020</a>, this is the sequel — six years later, we’re solving the <code class="language-plaintext highlighter-rouge">NoSuchElementException</code> problem at the root.</p>

<h2 id="why-locators-break-and-why-manual-fixing-doesnt-scale">Why Locators Break (And Why Manual Fixing Doesn’t Scale)</h2>

<p>In 2020, the advice was simple: use stable locators. Prefer <code class="language-plaintext highlighter-rouge">By.id()</code> over <code class="language-plaintext highlighter-rouge">By.xpath()</code>. Build a Page Object layer. Hope for the best.</p>

<p>In 2026, the reality hasn’t changed — frontend teams still refactor CSS, rename components, and migrate from Bootstrap to Tailwind to whatever comes next. The only difference is that <strong>we no longer have to fix the breakage manually</strong>.</p>

<pre><code class="language-mermaid">flowchart TD
    A["🟢 Test Suite — All Green"] --&gt; B["🔨 Frontend PR merged:&lt;br/&gt;CSS class renamed,&lt;br/&gt;DOM structure changed"]
    B --&gt; C["🔴 CI/CD Pipeline:&lt;br/&gt;19 of 23 tests fail&lt;br/&gt;NoSuchElementException"]
    C --&gt; D{"Traditional approach"}
    D --&gt;|Manual| E["👤 QA spends 4 hours&lt;br/&gt;updating Page Objects"]
    D --&gt;|Self-Healing| F["🤖 AI analyzes failures,&lt;br/&gt;finds elements by semantic role,&lt;br/&gt;auto-updates locators"]

    E --&gt; G["🟢 Suite green again&lt;br/&gt;(until next Friday)"]
    F --&gt; H["🟢 Suite green again&lt;br/&gt;+ healing log committed&lt;br/&gt;for human review"]

    style F fill:#34d399,color:#000
    style E fill:#f87171,color:#fff
</code></pre>

<p>The self-healing path doesn’t just fix the tests — it <strong>documents what changed</strong> so you can review the healing decisions and feed them back to the frontend team.</p>

<h2 id="how-self-healing-works-the-three-layer-strategy">How Self-Healing Works: The Three-Layer Strategy</h2>

<p>Self-healing isn’t one technique — it’s a <strong>layered defense</strong> that falls back through increasingly intelligent strategies:</p>

<pre><code class="language-mermaid">flowchart TD
    START["📍 Test tries to find element"] --&gt; L1{"Strategy 1&lt;br/&gt;Relative Locator&lt;br/&gt;Still valid?"}

    L1 --&gt;|Yes| FOUND["✅ Element found&lt;br/&gt;Test continues"]
    L1 --&gt;|No| L2{"Strategy 2&lt;br/&gt;Semantic Role&lt;br/&gt;Match found?"}

    L2 --&gt;|Yes| HEAL["🩹 Heal: update locator&lt;br/&gt;to semantic match&lt;br/&gt;+ log change"]
    L2 --&gt;|No| L3{"Strategy 3&lt;br/&gt;DOM Diff&lt;br/&gt;Element moved?"}

    L3 --&gt;|Yes| HEAL2["🩹 Heal: update locator&lt;br/&gt;to new DOM path&lt;br/&gt;+ log change"]
    L3 --&gt;|No| FAIL["❌ Report failure&lt;br/&gt;Human triage needed"]

    HEAL --&gt; FOUND
    HEAL2 --&gt; FOUND
</code></pre>

<p>Each layer is progressively more expensive but also more powerful. Let’s build all three — in Java.</p>

<h2 id="strategy-1-relative-locators--your-first-line-of-defense">Strategy 1: Relative Locators — Your First Line of Defense</h2>

<p>Before AI gets involved, use the simplest tool Selenium 4 gives you: <strong>Relative Locators</strong>. Instead of <code class="language-plaintext highlighter-rouge">By.cssSelector(".btn-submit")</code>, describe where the element <em>lives</em> on the page:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebElement</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.chrome.ChromeDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.support.locators.RelativeLocator</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RelativeLocatorExample</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">main</span><span class="o">(</span><span class="nc">String</span><span class="o">[]</span> <span class="n">args</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">WebDriver</span> <span class="n">driver</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ChromeDriver</span><span class="o">();</span>
        <span class="n">driver</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"https://your-app.com/login"</span><span class="o">);</span>

        <span class="c1">// ❌ Brittle: this breaks when the CSS class changes</span>
        <span class="c1">// WebElement submitBtn = driver.findElement(By.cssSelector(".btn-submit"));</span>

        <span class="c1">// ✅ Resilient: find the button BELOW the email field</span>
        <span class="nc">WebElement</span> <span class="n">emailField</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">name</span><span class="o">(</span><span class="s">"email"</span><span class="o">));</span>
        <span class="nc">WebElement</span> <span class="n">submitBtn</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span>
            <span class="nc">RelativeLocator</span><span class="o">.</span><span class="na">with</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">tagName</span><span class="o">(</span><span class="s">"button"</span><span class="o">))</span>
                <span class="o">.</span><span class="na">below</span><span class="o">(</span><span class="n">emailField</span><span class="o">)</span>
        <span class="o">);</span>

        <span class="c1">// ✅ Resilient: find the error message NEAR the password field</span>
        <span class="nc">WebElement</span> <span class="n">passwordField</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">name</span><span class="o">(</span><span class="s">"password"</span><span class="o">));</span>
        <span class="nc">WebElement</span> <span class="n">errorMsg</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span>
            <span class="nc">RelativeLocator</span><span class="o">.</span><span class="na">with</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">className</span><span class="o">(</span><span class="s">"error-message"</span><span class="o">))</span>
                <span class="o">.</span><span class="na">near</span><span class="o">(</span><span class="n">passwordField</span><span class="o">)</span>
        <span class="o">);</span>

        <span class="n">submitBtn</span><span class="o">.</span><span class="na">click</span><span class="o">();</span>
        <span class="n">driver</span><span class="o">.</span><span class="na">quit</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Relative Locators survive CSS class renames because they don’t depend on class names. They depend on <strong>spatial relationships</strong> — what’s above, below, or near another element. As long as the layout doesn’t radically change, the locator holds.</p>

<p><strong>Limitation:</strong> If the entire DOM structure shifts (e.g., a redesign that moves the login form to a modal), Relative Locators fail too. That’s where Strategy 2 comes in.</p>

<h2 id="strategy-2-ai-powered-semantic-role-matching">Strategy 2: AI-Powered Semantic Role Matching</h2>

<p>When both CSS selectors and Relative Locators fail, the AI healer asks a different question: <strong>“What is this element’s job on the page?”</strong></p>

<p>Instead of hunting for <code class="language-plaintext highlighter-rouge">.btn-submit</code> or even a <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code> below the email field, it looks for an element whose <strong>semantic role</strong> matches the intent of the test step:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebElement</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.ArrayList</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.List</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Map</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Optional</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.time.Instant</span><span class="o">;</span>

<span class="cm">/**
 * AI-powered semantic locator healer.
 *
 * When a traditional selector fails, this healer analyzes the page's
 * accessibility tree and finds elements by their ARIA role, accessible
 * name, and surrounding context — the same cues a human uses.
 */</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SemanticHealer</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">HealingRecord</span><span class="o">&gt;</span> <span class="n">healingLog</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">SemanticHealer</span><span class="o">(</span><span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">driver</span> <span class="o">=</span> <span class="n">driver</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">healingLog</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;&gt;();</span>
    <span class="o">}</span>

    <span class="cm">/**
     * Try to find an element by its semantic description.
     *
     * @param originalLocator  The locator that failed (for logging)
     * @param role             Expected ARIA role: "button", "textbox", "link", etc.
     * @param accessibleName   The accessible name or label text
     * @param context          Optional nearby element text for disambiguation
     * @return The healed element, or empty if no match found
     */</span>
    <span class="kd">public</span> <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">WebElement</span><span class="o">&gt;</span> <span class="nf">heal</span><span class="o">(</span>
            <span class="nc">By</span> <span class="n">originalLocator</span><span class="o">,</span>
            <span class="nc">String</span> <span class="n">role</span><span class="o">,</span>
            <span class="nc">String</span> <span class="n">accessibleName</span><span class="o">,</span>
            <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">context</span><span class="o">)</span> <span class="o">{</span>

        <span class="c1">// Step 1: Query the accessibility tree via CDP</span>
        <span class="c1">// The browser maintains an accessibility snapshot that maps</span>
        <span class="c1">// every interactive element → { role, name, description }</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;&gt;</span> <span class="n">accessibilityTree</span> <span class="o">=</span> <span class="n">queryAccessibilityTree</span><span class="o">();</span>

        <span class="c1">// Step 2: Find candidates matching the semantic description</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;&gt;</span> <span class="n">candidates</span> <span class="o">=</span> <span class="n">accessibilityTree</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="n">node</span> <span class="o">-&gt;</span> <span class="n">role</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">node</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"role"</span><span class="o">)))</span>
            <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="n">node</span> <span class="o">-&gt;</span> <span class="n">node</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"name"</span><span class="o">).</span><span class="na">contains</span><span class="o">(</span><span class="n">accessibleName</span><span class="o">))</span>
            <span class="o">.</span><span class="na">toList</span><span class="o">();</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">candidates</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">logHealing</span><span class="o">(</span><span class="n">originalLocator</span><span class="o">,</span> <span class="kc">null</span><span class="o">,</span> <span class="s">"No semantic match found"</span><span class="o">);</span>
            <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">empty</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="c1">// Step 3: Disambiguate — if context is provided, pick the</span>
        <span class="c1">// button nearest to that context text</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">bestMatch</span><span class="o">;</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">context</span><span class="o">.</span><span class="na">isPresent</span><span class="o">()</span> <span class="o">&amp;&amp;</span> <span class="n">candidates</span><span class="o">.</span><span class="na">size</span><span class="o">()</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">bestMatch</span> <span class="o">=</span> <span class="n">candidates</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
                <span class="o">.</span><span class="na">min</span><span class="o">((</span><span class="n">a</span><span class="o">,</span> <span class="n">b</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="o">{</span>
                    <span class="kt">double</span> <span class="n">distA</span> <span class="o">=</span> <span class="n">distanceFromContext</span><span class="o">(</span><span class="n">a</span><span class="o">,</span> <span class="n">context</span><span class="o">.</span><span class="na">get</span><span class="o">());</span>
                    <span class="kt">double</span> <span class="n">distB</span> <span class="o">=</span> <span class="n">distanceFromContext</span><span class="o">(</span><span class="n">b</span><span class="o">,</span> <span class="n">context</span><span class="o">.</span><span class="na">get</span><span class="o">());</span>
                    <span class="k">return</span> <span class="nc">Double</span><span class="o">.</span><span class="na">compare</span><span class="o">(</span><span class="n">distA</span><span class="o">,</span> <span class="n">distB</span><span class="o">);</span>
                <span class="o">})</span>
                <span class="o">.</span><span class="na">orElse</span><span class="o">(</span><span class="n">candidates</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="mi">0</span><span class="o">));</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="n">bestMatch</span> <span class="o">=</span> <span class="n">candidates</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="c1">// Step 4: Convert the accessibility node back to a WebElement</span>
        <span class="nc">String</span> <span class="n">backendNodeId</span> <span class="o">=</span> <span class="n">bestMatch</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"backendNodeId"</span><span class="o">);</span>
        <span class="nc">WebElement</span> <span class="n">healed</span> <span class="o">=</span> <span class="n">resolveElement</span><span class="o">(</span><span class="n">backendNodeId</span><span class="o">);</span>

        <span class="n">logHealing</span><span class="o">(</span><span class="n">originalLocator</span><span class="o">,</span> <span class="n">healed</span><span class="o">,</span>
            <span class="nc">String</span><span class="o">.</span><span class="na">format</span><span class="o">(</span><span class="s">"Healed via semantic role='%s', name='%s'"</span><span class="o">,</span>
                <span class="n">role</span><span class="o">,</span> <span class="n">accessibleName</span><span class="o">));</span>

        <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">healed</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="c1">// --- Implementation details ---</span>

    <span class="kd">private</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;&gt;</span> <span class="nf">queryAccessibilityTree</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// In production, use the CDP session directly:</span>
        <span class="c1">//   var cdp = ((ChromeDriver) driver).getDevTools();</span>
        <span class="c1">//   var axTree = cdp.send(Accessibility.getFullAXTree(Optional.of(5), Optional.empty()));</span>
        <span class="c1">//   return parseAxTree(axTree.getNodes());</span>
        <span class="c1">//</span>
        <span class="c1">// For this illustration, we return a mock accessibility tree</span>
        <span class="c1">// with a "Sign In" button that the healer can find:</span>
        <span class="k">return</span> <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
            <span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"role"</span><span class="o">,</span> <span class="s">"textbox"</span><span class="o">,</span> <span class="s">"name"</span><span class="o">,</span> <span class="s">"Email"</span><span class="o">,</span> <span class="s">"backendNodeId"</span><span class="o">,</span> <span class="s">"101"</span><span class="o">),</span>
            <span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"role"</span><span class="o">,</span> <span class="s">"textbox"</span><span class="o">,</span> <span class="s">"name"</span><span class="o">,</span> <span class="s">"Password"</span><span class="o">,</span> <span class="s">"backendNodeId"</span><span class="o">,</span> <span class="s">"102"</span><span class="o">),</span>
            <span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"role"</span><span class="o">,</span> <span class="s">"button"</span><span class="o">,</span> <span class="s">"name"</span><span class="o">,</span> <span class="s">"Sign In"</span><span class="o">,</span> <span class="s">"backendNodeId"</span><span class="o">,</span> <span class="s">"103"</span><span class="o">)</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">double</span> <span class="nf">distanceFromContext</span><span class="o">(</span><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">node</span><span class="o">,</span> <span class="nc">String</span> <span class="n">contextText</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Compute Euclidean distance between the candidate element's</span>
        <span class="c1">// bounding box and the element containing the context text</span>
        <span class="k">return</span> <span class="mf">0.0</span><span class="o">;</span> <span class="c1">// Simplified</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">WebElement</span> <span class="nf">resolveElement</span><span class="o">(</span><span class="nc">String</span> <span class="n">backendNodeId</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Convert CDP backend node ID → DOM node → WebElement</span>
        <span class="k">return</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">id</span><span class="o">(</span><span class="s">"resolved-"</span> <span class="o">+</span> <span class="n">backendNodeId</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">logHealing</span><span class="o">(</span><span class="nc">By</span> <span class="n">original</span><span class="o">,</span> <span class="nc">WebElement</span> <span class="n">healed</span><span class="o">,</span> <span class="nc">String</span> <span class="n">reason</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">healingLog</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="k">new</span> <span class="nc">HealingRecord</span><span class="o">(</span>
            <span class="n">original</span><span class="o">.</span><span class="na">toString</span><span class="o">(),</span>
            <span class="n">healed</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">healed</span><span class="o">.</span><span class="na">toString</span><span class="o">()</span> <span class="o">:</span> <span class="s">"NOT_FOUND"</span><span class="o">,</span>
            <span class="n">reason</span><span class="o">,</span>
            <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">()</span>
        <span class="o">));</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">printf</span><span class="o">(</span><span class="s">"[HEAL] %s → %s%n"</span><span class="o">,</span> <span class="n">original</span><span class="o">,</span> <span class="n">reason</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">HealingRecord</span><span class="o">&gt;</span> <span class="nf">getHealingLog</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">List</span><span class="o">.</span><span class="na">copyOf</span><span class="o">(</span><span class="n">healingLog</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="c1">// --- Inner types ---</span>

    <span class="kd">public</span> <span class="n">record</span> <span class="nf">HealingRecord</span><span class="o">(</span>
        <span class="nc">String</span> <span class="n">originalLocator</span><span class="o">,</span>
        <span class="nc">String</span> <span class="n">healedLocator</span><span class="o">,</span>
        <span class="nc">String</span> <span class="n">reason</span><span class="o">,</span>
        <span class="nc">Instant</span> <span class="n">timestamp</span>
    <span class="o">)</span> <span class="o">{}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="using-the-semantic-healer-in-a-real-test">Using the Semantic Healer in a Real Test</h3>

<p>Here’s how a test wraps every <code class="language-plaintext highlighter-rouge">findElement</code> call with healing fallback:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.junit.jupiter.api.AfterEach</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.junit.jupiter.api.BeforeEach</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.junit.jupiter.api.Test</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">static</span> <span class="n">org</span><span class="o">.</span><span class="na">junit</span><span class="o">.</span><span class="na">jupiter</span><span class="o">.</span><span class="na">api</span><span class="o">.</span><span class="na">Assertions</span><span class="o">.*;</span>

<span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.NoSuchElementException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebElement</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.chrome.ChromeDriver</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.List</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Optional</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoginTest</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
    <span class="kd">private</span> <span class="nc">SemanticHealer</span> <span class="n">healer</span><span class="o">;</span>

    <span class="nd">@BeforeEach</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">setup</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">driver</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ChromeDriver</span><span class="o">();</span>
        <span class="n">healer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SemanticHealer</span><span class="o">(</span><span class="n">driver</span><span class="o">);</span>
        <span class="n">driver</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"https://your-app.com/login"</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testLoginWithHealing</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// Try the primary locator</span>
        <span class="nc">WebElement</span> <span class="n">submitBtn</span><span class="o">;</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">submitBtn</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">".btn-submit"</span><span class="o">));</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">NoSuchElementException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="c1">// Primary failed — heal using semantic role</span>
            <span class="n">submitBtn</span> <span class="o">=</span> <span class="n">healer</span><span class="o">.</span><span class="na">heal</span><span class="o">(</span>
                <span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">".btn-submit"</span><span class="o">),</span>    <span class="c1">// original (broken)</span>
                <span class="s">"button"</span><span class="o">,</span>                         <span class="c1">// ARIA role</span>
                <span class="s">"Sign In"</span><span class="o">,</span>                        <span class="c1">// accessible name</span>
                <span class="nc">Optional</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"Password"</span><span class="o">)</span>           <span class="c1">// context: near "Password" field</span>
            <span class="o">).</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">AssertionError</span><span class="o">(</span>
                <span class="s">"Could not find submit button — even semantic healing failed"</span><span class="o">));</span>
        <span class="o">}</span>

        <span class="n">submitBtn</span><span class="o">.</span><span class="na">click</span><span class="o">();</span>

        <span class="c1">// Verify the healing log captured the change</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">SemanticHealer</span><span class="o">.</span><span class="na">HealingRecord</span><span class="o">&gt;</span> <span class="n">log</span> <span class="o">=</span> <span class="n">healer</span><span class="o">.</span><span class="na">getHealingLog</span><span class="o">();</span>
        <span class="n">assertFalse</span><span class="o">(</span><span class="n">log</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">(),</span> <span class="s">"Expected at least one healing event"</span><span class="o">);</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Healing log: "</span> <span class="o">+</span> <span class="n">log</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@AfterEach</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">teardown</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">driver</span><span class="o">.</span><span class="na">quit</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<pre><code class="language-mermaid">sequenceDiagram
    participant Test as Test Code
    participant Driver as WebDriver
    participant Healer as SemanticHealer
    participant Browser as Chrome (CDP)

    Test-&gt;&gt;Driver: findElement(By.cssSelector(".btn-submit"))
    Driver-&gt;&gt;Browser: Query DOM
    Browser--&gt;&gt;Driver: NoSuchElementException

    Test-&gt;&gt;Healer: heal(role="button", name="Sign In")
    Healer-&gt;&gt;Browser: CDP: Accessibility.getFullAXTree()
    Browser--&gt;&gt;Healer: AXTree JSON:&lt;br/&gt;{ role: "button", name: "Sign In", backendNodeId: 42 }
    Healer-&gt;&gt;Healer: Match found: role=button, name="Sign In"
    Healer-&gt;&gt;Browser: CDP: DOM.resolveNode(backendNodeId=42)
    Browser--&gt;&gt;Healer: DOM node reference
    Healer--&gt;&gt;Test: WebElement (healed)
    Healer-&gt;&gt;Healer: Log: .btn-submit → role=button, name=Sign In

    Test-&gt;&gt;Test: submitBtn.click() ✅
</code></pre>

<h2 id="strategy-3-bidicdp-dom-diff--when-the-element-moved">Strategy 3: BiDi/CDP DOM Diff — When the Element Moved</h2>

<p>Sometimes the element exists but in a completely different part of the DOM — a redesign moved the login form from a sidebar to a modal. Semantic healing might find it, but the locator path is now entirely wrong.</p>

<p>This is where BiDi (or CDP) DOM diffing comes in:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * Uses WebDriver BiDi to capture DOM snapshots before and after
 * a UI change, then diffs them to find moved elements.
 */</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">DomDiffHealer</span> <span class="o">{</span>

    <span class="cm">/**
     * Compare two DOM snapshots and find where a given element moved.
     *
     * @param elementDescription  Semantic description: { role, name }
     * @param oldSnapshot         DOM snapshot from the last known-good run
     * @return The element's new CSS selector path, or empty if not found
     */</span>
    <span class="kd">public</span> <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="nf">findMovedElement</span><span class="o">(</span>
            <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">elementDescription</span><span class="o">,</span>
            <span class="nc">String</span> <span class="n">oldSnapshot</span><span class="o">)</span> <span class="o">{</span>

        <span class="c1">// Step 1: Capture current DOM via BiDi</span>
        <span class="nc">String</span> <span class="n">newSnapshot</span> <span class="o">=</span> <span class="n">captureDomSnapshot</span><span class="o">();</span>

        <span class="c1">// Step 2: Find the element in the old snapshot by its</span>
        <span class="c1">// semantic fingerprint (role + name + text content)</span>
        <span class="nc">String</span> <span class="n">oldFingerprint</span> <span class="o">=</span> <span class="n">extractFingerprint</span><span class="o">(</span><span class="n">oldSnapshot</span><span class="o">,</span> <span class="n">elementDescription</span><span class="o">);</span>

        <span class="c1">// Step 3: Search the new snapshot for the same fingerprint</span>
        <span class="nc">String</span> <span class="n">newSelector</span> <span class="o">=</span> <span class="n">searchByFingerprint</span><span class="o">(</span><span class="n">newSnapshot</span><span class="o">,</span> <span class="n">oldFingerprint</span><span class="o">);</span>

        <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">ofNullable</span><span class="o">(</span><span class="n">newSelector</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">captureDomSnapshot</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// BiDi: browsingContext.captureSnapshot() → returns full DOM as string</span>
        <span class="c1">// This gives a complete, serialized DOM tree at the current moment</span>
        <span class="k">return</span> <span class="s">""</span><span class="o">;</span> <span class="c1">// Simplified</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">extractFingerprint</span><span class="o">(</span><span class="nc">String</span> <span class="n">snapshot</span><span class="o">,</span> <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">desc</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Find the element in the snapshot that matches { role, name }</span>
        <span class="c1">// and extract its structural fingerprint (tag, attributes, text)</span>
        <span class="k">return</span> <span class="s">""</span><span class="o">;</span> <span class="c1">// Simplified</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">searchByFingerprint</span><span class="o">(</span><span class="nc">String</span> <span class="n">newSnapshot</span><span class="o">,</span> <span class="nc">String</span> <span class="n">fingerprint</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Walk the new DOM looking for a node whose structural</span>
        <span class="c1">// fingerprint matches within a similarity threshold</span>
        <span class="k">return</span> <span class="kc">null</span><span class="o">;</span> <span class="c1">// Simplified</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="the-full-three-layer-healing-wrapper">The Full Three-Layer Healing Wrapper</h3>

<p>Wrap all three strategies into a single <code class="language-plaintext highlighter-rouge">findElement</code> replacement:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.NoSuchElementException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebElement</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.chrome.ChromeDriver</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.ArrayList</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.List</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Map</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Optional</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.time.Instant</span><span class="o">;</span>

<span class="cm">/**
 * SelfHealingDriver: a WebDriver wrapper that automatically heals
 * broken locators using the three-layer strategy.
 *
 * Usage:
 *   SelfHealingDriver driver = new SelfHealingDriver(new ChromeDriver());
 *   WebElement btn = driver.findElement(
 *       By.cssSelector(".btn-submit"),           // primary
 *       "button",                                 // ARIA role
 *       "Sign In",                                // accessible name
 *       Optional.of("Password")                   // context
 *   );
 */</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SelfHealingDriver</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SemanticHealer</span> <span class="n">semanticHealer</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">DomDiffHealer</span> <span class="n">domDiffHealer</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">HealingRecord</span><span class="o">&gt;</span> <span class="n">healingLog</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">SelfHealingDriver</span><span class="o">(</span><span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">driver</span> <span class="o">=</span> <span class="n">driver</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">semanticHealer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SemanticHealer</span><span class="o">(</span><span class="n">driver</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">domDiffHealer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DomDiffHealer</span><span class="o">();</span>
        <span class="k">this</span><span class="o">.</span><span class="na">healingLog</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;&gt;();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">WebElement</span> <span class="nf">findElement</span><span class="o">(</span>
            <span class="nc">By</span> <span class="n">primaryLocator</span><span class="o">,</span>
            <span class="nc">String</span> <span class="n">role</span><span class="o">,</span>
            <span class="nc">String</span> <span class="n">accessibleName</span><span class="o">,</span>
            <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">context</span><span class="o">)</span> <span class="o">{</span>

        <span class="c1">// Layer 1: try the primary locator</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">primaryLocator</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">NoSuchElementException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"[HEAL] Primary locator failed: "</span> <span class="o">+</span> <span class="n">primaryLocator</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="c1">// Layer 2: semantic role matching via CDP accessibility tree</span>
        <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">WebElement</span><span class="o">&gt;</span> <span class="n">healed</span> <span class="o">=</span> <span class="n">semanticHealer</span><span class="o">.</span><span class="na">heal</span><span class="o">(</span>
            <span class="n">primaryLocator</span><span class="o">,</span> <span class="n">role</span><span class="o">,</span> <span class="n">accessibleName</span><span class="o">,</span> <span class="n">context</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">healed</span><span class="o">.</span><span class="na">isPresent</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">healingLog</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="k">new</span> <span class="nc">HealingRecord</span><span class="o">(</span><span class="n">primaryLocator</span><span class="o">.</span><span class="na">toString</span><span class="o">(),</span>
                <span class="s">"role="</span> <span class="o">+</span> <span class="n">role</span> <span class="o">+</span> <span class="s">", name="</span> <span class="o">+</span> <span class="n">accessibleName</span><span class="o">,</span>
                <span class="s">"SEMANTIC"</span><span class="o">,</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">()));</span>
            <span class="k">return</span> <span class="n">healed</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="c1">// Layer 3: BiDi DOM diff — has the element moved?</span>
        <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">newSelector</span> <span class="o">=</span> <span class="n">domDiffHealer</span><span class="o">.</span><span class="na">findMovedElement</span><span class="o">(</span>
            <span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"role"</span><span class="o">,</span> <span class="n">role</span><span class="o">,</span> <span class="s">"name"</span><span class="o">,</span> <span class="n">accessibleName</span><span class="o">),</span>
            <span class="n">getLastKnownGoodSnapshot</span><span class="o">()</span>
        <span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">newSelector</span><span class="o">.</span><span class="na">isPresent</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">healingLog</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="k">new</span> <span class="nc">HealingRecord</span><span class="o">(</span><span class="n">primaryLocator</span><span class="o">.</span><span class="na">toString</span><span class="o">(),</span>
                <span class="n">newSelector</span><span class="o">.</span><span class="na">get</span><span class="o">(),</span> <span class="s">"DOM_DIFF"</span><span class="o">,</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">()));</span>
            <span class="k">return</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="n">newSelector</span><span class="o">.</span><span class="na">get</span><span class="o">()));</span>
        <span class="o">}</span>

        <span class="c1">// All strategies exhausted — fail with a clear message</span>
        <span class="nc">String</span> <span class="n">msg</span> <span class="o">=</span> <span class="nc">String</span><span class="o">.</span><span class="na">format</span><span class="o">(</span>
            <span class="s">"Self-healing exhausted for %s (role=%s, name=%s). Manual triage required."</span><span class="o">,</span>
            <span class="n">primaryLocator</span><span class="o">,</span> <span class="n">role</span><span class="o">,</span> <span class="n">accessibleName</span><span class="o">);</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NoSuchElementException</span><span class="o">(</span><span class="n">msg</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">HealingRecord</span><span class="o">&gt;</span> <span class="nf">getHealingLog</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="nc">List</span><span class="o">.</span><span class="na">copyOf</span><span class="o">(</span><span class="n">healingLog</span><span class="o">);</span> <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">getLastKnownGoodSnapshot</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// In CI/CD: fetch from artifact storage (S3, GCS, or GitHub Artifacts)</span>
        <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="c1">// Delegate all standard WebDriver methods to the underlying driver</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">get</span><span class="o">(</span><span class="nc">String</span> <span class="n">url</span><span class="o">)</span> <span class="o">{</span> <span class="n">driver</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">url</span><span class="o">);</span> <span class="o">}</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">getTitle</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="n">driver</span><span class="o">.</span><span class="na">getTitle</span><span class="o">();</span> <span class="o">}</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">quit</span><span class="o">()</span> <span class="o">{</span> <span class="n">driver</span><span class="o">.</span><span class="na">quit</span><span class="o">();</span> <span class="o">}</span>

    <span class="kd">public</span> <span class="n">record</span> <span class="nf">HealingRecord</span><span class="o">(</span>
        <span class="nc">String</span> <span class="n">original</span><span class="o">,</span> <span class="nc">String</span> <span class="n">healed</span><span class="o">,</span> <span class="nc">String</span> <span class="n">strategy</span><span class="o">,</span> <span class="nc">Instant</span> <span class="n">timestamp</span>
    <span class="o">)</span> <span class="o">{}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="cicd-integration-commit-the-healing-log">CI/CD Integration: Commit the Healing Log</h2>

<p>The final piece: when healing happens in CI/CD, <strong>commit the log as a build artifact</strong> so a human can review the AI’s decisions:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/selenium-tests.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Self-Healing Selenium Tests</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">test</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up JDK </span><span class="m">21</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-java@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">java-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">21'</span>
          <span class="na">distribution</span><span class="pi">:</span> <span class="s1">'</span><span class="s">temurin'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run tests with self-healing</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">mvn test -Dtest=LoginTest,CheckoutTest</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload healing log</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">always()</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">healing-log</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">target/healing-log.json</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Comment healing summary on PR</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">always()</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">HEAL_COUNT=$(jq '. | length' target/healing-log.json)</span>
          <span class="s">if [ "$HEAL_COUNT" -gt 0 ]; then</span>
            <span class="s">echo "## 🤖 Self-Healing Report" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
            <span class="s">echo "**$HEAL_COUNT locator(s) healed.** Review the changes:" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
            <span class="s">jq -r '.[] | "- `\(.original)` → `\(.healed)` (\(.strategy))"' \</span>
              <span class="s">target/healing-log.json &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">fi</span>
</code></pre></div></div>

<p>After the run, your PR gets a summary like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>## 🤖 Self-Healing Report
**3 locator(s) healed. Review the changes:**
- `.btn-submit` → role=button, name=Sign In (SEMANTIC)
- `#checkout-form` → role=form, name=Checkout (SEMANTIC)
- `.cart-item-0` → li:nth-child(1)[data-testid=cart-item] (DOM_DIFF)
</code></pre></div></div>

<p>The healing log doubles as a <strong>change notification for the frontend team</strong> — they can see exactly which selectors broke and why.</p>

<blockquote>
  <p><strong>Wiring it up:</strong> The <code class="language-plaintext highlighter-rouge">SemanticHealer</code> and <code class="language-plaintext highlighter-rouge">SelfHealingDriver</code> in this post store healing records in an in-memory <code class="language-plaintext highlighter-rouge">ArrayList</code>. To bridge to the CI/CD workflow above, add a <code class="language-plaintext highlighter-rouge">writeHealingLog()</code> method to your healer that serializes <code class="language-plaintext highlighter-rouge">getHealingLog()</code> to <code class="language-plaintext highlighter-rouge">target/healing-log.json</code> (Jackson or Gson), and call it from your <code class="language-plaintext highlighter-rouge">@AfterAll</code> / <code class="language-plaintext highlighter-rouge">@AfterSuite</code> teardown hook. That’s the last mile — everything else in the workflow is ready to go.</p>
</blockquote>

<h2 id="when-to-use-each-strategy">When to Use Each Strategy</h2>

<pre><code class="language-mermaid">flowchart TD
    START["📍 Element not found"] --&gt; Q1{"Is there a stable&lt;br/&gt;element nearby?"}
    Q1 --&gt;|Yes| REL["Strategy 1&lt;br/&gt;Relative Locator&lt;br/&gt;below() / near() / above()"]
    Q1 --&gt;|No| Q2{"Does the element have&lt;br/&gt;a clear semantic role?&lt;br/&gt;(button, textbox, link)"}
    Q2 --&gt;|Yes| SEM["Strategy 2&lt;br/&gt;AI Semantic Healing&lt;br/&gt;accessibility tree query"]
    Q2 --&gt;|No| Q3{"Did the DOM structure&lt;br/&gt;change significantly?"}
    Q3 --&gt;|Yes| DIFF["Strategy 3&lt;br/&gt;BiDi/CDP DOM Diff&lt;br/&gt;find by fingerprint"]
    Q3 --&gt;|No| FAIL["❌ Manual triage&lt;br/&gt;Human reviews the page"]
    REL --&gt; OK["✅ Healed"]
    SEM --&gt; OK
    DIFF --&gt; OK
</code></pre>

<h2 id="multi-language-quick-reference">Multi-Language Quick Reference</h2>

<p>This post used Java examples. Here’s the equivalent syntax across <strong>C#</strong>, <strong>TypeScript</strong>, <strong>JavaScript</strong>, and <strong>Python</strong>:</p>

<h3 id="relative-locators">Relative Locators</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Below / Near / Above</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">RelativeLocator.with(By.tagName("button")).below(emailField)</code></td>
    </tr>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">RelativeBy.WithLocator(By.TagName("button")).Below(emailField)</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">driver.findElement(locateWith(By.tagName('button')).below(emailField))</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">driver.findElement(locateWith(By.tagName('button')).below(emailField))</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">driver.find_element(locate_with(By.TAG_NAME, "button").below(email_field))</code></td>
    </tr>
  </tbody>
</table>

<h3 id="accessibility-tree-query-cdp">Accessibility Tree Query (CDP)</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Accessing the full AX tree</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">devTools.send(Accessibility.getFullAXTree(Optional.of(5), Optional.empty()))</code></td>
    </tr>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">var axTree = await session.SendAsync(Accessibility.GetFullAXTree());</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const axTree = await cdp.send('Accessibility.getFullAXTree');</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const axTree = await cdp.send('Accessibility.getFullAXTree');</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">ax_tree = await cdp_session.send('Accessibility.getFullAXTree')</code></td>
    </tr>
  </tbody>
</table>

<h3 id="selfhealingdriver-wrapper">SelfHealingDriver Wrapper</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Constructor pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">SelfHealingDriver driver = new SelfHealingDriver(new ChromeDriver());</code></td>
    </tr>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">using SelfHealingDriver driver = new(new ChromeDriver());</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const driver = new SelfHealingDriver(new Builder().forBrowser('chrome').build());</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const driver = new SelfHealingDriver(new Builder().forBrowser('chrome').build());</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">driver = SelfHealingDriver(webdriver.Chrome())</code></td>
    </tr>
  </tbody>
</table>

<h3 id="cicd-healing-log-upload">CI/CD Healing Log Upload</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Test framework</th>
      <th>Healing log artifact</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Java</strong></td>
      <td>JUnit 5 / TestNG</td>
      <td><code class="language-plaintext highlighter-rouge">target/healing-log.json</code></td>
    </tr>
    <tr>
      <td><strong>C#</strong></td>
      <td>xUnit / NUnit</td>
      <td><code class="language-plaintext highlighter-rouge">TestResults/healing-log.json</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td>Jest / Mocha</td>
      <td><code class="language-plaintext highlighter-rouge">test-results/healing-log.json</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td>Jest / Mocha</td>
      <td><code class="language-plaintext highlighter-rouge">test-results/healing-log.json</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td>pytest</td>
      <td><code class="language-plaintext highlighter-rouge">reports/healing-log.json</code></td>
    </tr>
  </tbody>
</table>

<h2 id="where-existing-posts-fit">Where Existing Posts Fit</h2>

<table>
  <thead>
    <tr>
      <th>Earlier post</th>
      <th>What it covered</th>
      <th>What this post adds</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/selenium-page-locator-strategies/">Selenium Page Locator Strategies (May 2020)</a></td>
      <td><code class="language-plaintext highlighter-rouge">By.id()</code>, <code class="language-plaintext highlighter-rouge">By.xpath()</code>, CSS selectors, implicit/explicit waits</td>
      <td>Six years later: AI finds elements by semantic role when every traditional locator fails</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium 2026 Beginner’s Guide (Jul 2026)</a></td>
      <td>Relative Locators, WebDriver BiDi, MCP server</td>
      <td>BiDi’s accessibility tree + DOM snapshot become the inputs to the healing engine</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">Selenium BiDi vs Playwright CDP (Jul 2026)</a></td>
      <td>Drag-and-drop, network interception, AI replay</td>
      <td>DOM diff healing uses the same BiDi snapshot APIs introduced in that post</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy (Jun 2026)</a></td>
      <td>Phase 4: self-healing Cypress system (TypeScript)</td>
      <td>Phase 4 implemented in Java for Selenium — the pattern is universal across languages and frameworks</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive/">Playwright AI Codegen (Jul 2026)</a></td>
      <td>Generating tests from natural language</td>
      <td>Codegen creates the tests; self-healing keeps them alive — two halves of the autonomous testing pipeline</td>
    </tr>
  </tbody>
</table>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://www.selenium.dev/documentation/webdriver/elements/locators/#relative-locators">Selenium Relative Locators</a> — official docs for the <code class="language-plaintext highlighter-rouge">above()</code>, <code class="language-plaintext highlighter-rouge">below()</code>, <code class="language-plaintext highlighter-rouge">near()</code> API used in Strategy 1</li>
  <li><a href="https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/">Chrome DevTools Protocol — Accessibility Domain</a> — the CDP API (<code class="language-plaintext highlighter-rouge">Accessibility.getFullAXTree</code>) that powers the semantic healer in Strategy 2</li>
  <li><a href="https://www.selenium.dev/documentation/webdriver/bidi/cdp/">Selenium CDP (Chrome DevTools Protocol)</a> — official guide to using CDP sessions from Selenium, the bridge between BiDi and Chrome’s native protocol</li>
  <li><a href="https://github.com/angiejones/mcp-selenium">Angie Jones / mcp-selenium</a> — the MCP server whose accessibility tree query inspired the semantic healing approach</li>
</ol>

<h2 id="what-to-do-next">What to Do Next</h2>

<ol>
  <li><strong>Add Relative Locators today.</strong> It’s a zero-dependency change — replace your 5 most brittle CSS selectors with <code class="language-plaintext highlighter-rouge">RelativeLocator.with().below()</code> / <code class="language-plaintext highlighter-rouge">.near()</code>. You’ll see immediate stability improvement in the next CI run.</li>
  <li><strong>Prototype the SemanticHealer.</strong> Take the Java class from this post, wire it up to your existing test suite’s <code class="language-plaintext highlighter-rouge">findElement</code> calls, and run it against a page where you’ve deliberately broken a CSS class. Watch the accessibility tree heal it in real time.</li>
  <li><strong>Set up the healing log in CI/CD.</strong> Even without the full three-layer healing engine, logging every <code class="language-plaintext highlighter-rouge">NoSuchElementException</code> with the page’s accessibility snapshot gives you data to build the healer with.</li>
  <li><strong>For Playwright users:</strong> The same pattern works — replace <code class="language-plaintext highlighter-rouge">driver.findElement()</code> with <code class="language-plaintext highlighter-rouge">page.locator()</code> and the CDP accessibility query is identical. The <a href="/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive/">Playwright AI Codegen post</a> covers the TypeScript/JS equivalent.</li>
  <li><strong>Subscribe to this blog’s <a href="/feed.xml">feed.xml</a></strong> — next up: a practical guide to building an AI test oracle that judges “did the test actually pass?” by looking at screenshots, not assertions.</li>
</ol>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy: From Copilot to Multi-Agent Orchestration (Jun 2026)</a> — the Phase 4 Cypress self-healing implementation that inspired the Java Selenium version in this post. · <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation (Jul 2026)</a> — the story-mode article with §12 complex XPath &amp; CSS for SDETs (SVG, computed indices, ARIA chains, iframe/shadow-DOM, modern CSS <code class="language-plaintext highlighter-rouge">:has()</code>/<code class="language-plaintext highlighter-rouge">:is()</code>/<code class="language-plaintext highlighter-rouge">:where()</code>, decision flowchart).</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="best-practices" /><category term="selenium" /><category term="playwright" /><category term="self-healing" /><category term="ai-testing" /><category term="locators" /><category term="ci-cd" /><category term="java" /><category term="csharp" /><category term="typescript" /><category term="javascript" /><category term="python" /><summary type="html"><![CDATA[Frontend renamed a button and your suite went red at 2am? Self-healing locators find it by what it does, not what it's called.]]></summary></entry><entry><title type="html">Playwright AI Codegen in 2026: Generating Test Suites from Natural Language</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive/" rel="alternate" type="text/html" title="Playwright AI Codegen in 2026: Generating Test Suites from Natural Language" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive/"><![CDATA[<p>In the <a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">Playwright MCP guide</a>, you learned how to let an AI agent drive your browser through natural language. Now we go one step deeper: <strong>AI codegen</strong> — where Playwright <em>writes the test script for you</em>, not just executes your commands. You describe what you want in plain English, and Playwright generates idiomatic, maintainable code in your language of choice (C#, Python, TypeScript, or Java).</p>

<p>If Playwright MCP is like having an AI chauffeur drive your car, AI codegen is like the chauffeur writing you the driver’s manual — so you can replay the journey anytime, tweak the route, and commit it to CI/CD.</p>

<p>This post is the natural next step after the Selenium 2026 and Playwright MCP guides. No prior AI experience needed — just a working Playwright install.</p>

<h2 id="what-npx-playwright-codegen---ai-actually-does">What <code class="language-plaintext highlighter-rouge">npx playwright codegen --ai</code> Actually Does</h2>

<p>Traditional <code class="language-plaintext highlighter-rouge">playwright codegen</code> (no <code class="language-plaintext highlighter-rouge">--ai</code> flag) records your clicks and keystrokes and translates them into Playwright scripts mechanically:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Click on button #checkout  →  await page.ClickAsync("#checkout")
Type "test@example.com"   →  await page.FillAsync("#email", "test@example.com")
</code></pre></div></div>

<p>It works, but it’s brittle. The selectors are whatever CSS path Playwright guesses at the moment of recording. Change the DOM, and the script breaks.</p>

<p><strong>AI codegen</strong> (<code class="language-plaintext highlighter-rouge">--ai</code> flag, stable since Playwright v1.50) is fundamentally different:</p>

<pre><code class="language-mermaid">flowchart TD
    A["📝 Natural Language&lt;br/&gt;'Log in with admin credentials&lt;br/&gt;and verify the dashboard shows&lt;br/&gt;3 active projects'"] --&gt; B["🧠 Playwright AI Codegen Engine"]
    
    B --&gt; C["🔍 Page Analysis&lt;br/&gt;(DOM tree + accessibility snapshot)"]
    B --&gt; D["🧩 Step Planner&lt;br/&gt;(breaks intent into actions)"]
    
    C --&gt; E["🎯 Intent-Based Locator Generator"]
    D --&gt; E
    
    E --&gt; F["📍 Locators:&lt;br/&gt;page.GetByRole('button', { name: 'Sign In' })&lt;br/&gt;page.GetByLabel('Email')&lt;br/&gt;page.GetByText('3 active projects')"]
    
    D --&gt; G["📜 Script Generator&lt;br/&gt;(language-specific output)"]
    F --&gt; G
    
    G --&gt; H["💻 Generated Script&lt;br/&gt;await page.GetByLabel('Email').FillAsync('admin@test.com');&lt;br/&gt;await page.GetByRole('button', { name: 'Sign In' }).ClickAsync();&lt;br/&gt;await Expect(page.GetByTestId('project-count')).ToHaveTextAsync('3');"]
</code></pre>

<p>The engine doesn’t just record — it <strong>understands intent</strong>. Instead of generating <code class="language-plaintext highlighter-rouge">page.Locator("#email-17abc")</code>, it generates <code class="language-plaintext highlighter-rouge">page.GetByLabel("Email")</code> — a semantic locator that survives DOM reshuffles.</p>

<h2 id="step-1-basic-ai-codegen--from-sentence-to-script">Step 1: Basic AI Codegen — From Sentence to Script</h2>

<p>Open a terminal and run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx playwright codegen <span class="nt">--ai</span> https://your-app.com/login
</code></pre></div></div>

<p>A Chromium window opens alongside the Playwright Inspector. In the inspector, instead of clicking around, <strong>type what you want</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Log in with:
  - Email: admin@example.com
  - Password: SuperSecret123!
After login, verify the page title contains "Dashboard"
Click on "Projects" in the sidebar
Verify the project list has at least 5 items
</code></pre></div></div>

<p>Playwright watches the page, plans the steps, and generates:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Generated by Playwright AI Codegen — C# / NUnit</span>
<span class="k">using</span> <span class="nn">Microsoft.Playwright.NUnit</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">LoginFlowTests</span> <span class="p">:</span> <span class="n">PageTest</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">Test</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">AdminLoginAndVerifyProjects</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/login"</span><span class="p">);</span>

        <span class="c1">// Fill login form using semantic locators</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByLabel</span><span class="p">(</span><span class="s">"Email"</span><span class="p">).</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"admin@example.com"</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByLabel</span><span class="p">(</span><span class="s">"Password"</span><span class="p">).</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"SuperSecret123!"</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByRole</span><span class="p">(</span><span class="n">AriaRole</span><span class="p">.</span><span class="n">Button</span><span class="p">,</span> <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"Sign In"</span> <span class="p">}).</span><span class="nf">ClickAsync</span><span class="p">();</span>

        <span class="c1">// Web-First assertion: auto-waits for title match</span>
        <span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="nf">ToHaveTitleAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">System</span><span class="p">.</span><span class="n">Text</span><span class="p">.</span><span class="n">RegularExpressions</span><span class="p">.</span><span class="nf">Regex</span><span class="p">(</span><span class="s">"Dashboard"</span><span class="p">));</span>

        <span class="c1">// Navigate to Projects</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByRole</span><span class="p">(</span><span class="n">AriaRole</span><span class="p">.</span><span class="n">Link</span><span class="p">,</span> <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"Projects"</span> <span class="p">}).</span><span class="nf">ClickAsync</span><span class="p">();</span>

        <span class="c1">// Assert project list has at least 5 items</span>
        <span class="kt">var</span> <span class="n">projectItems</span> <span class="p">=</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByTestId</span><span class="p">(</span><span class="s">"project-list-item"</span><span class="p">);</span>
        <span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">projectItems</span><span class="p">.</span><span class="n">First</span><span class="p">).</span><span class="nf">ToBeVisibleAsync</span><span class="p">();</span>
        
        <span class="kt">var</span> <span class="n">count</span> <span class="p">=</span> <span class="k">await</span> <span class="n">projectItems</span><span class="p">.</span><span class="nf">CountAsync</span><span class="p">();</span>
        <span class="n">Assert</span><span class="p">.</span><span class="nf">That</span><span class="p">(</span><span class="n">count</span><span class="p">,</span> <span class="n">Is</span><span class="p">.</span><span class="nf">GreaterThanOrEqualTo</span><span class="p">(</span><span class="m">5</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<pre><code class="language-mermaid">sequenceDiagram
    participant You
    participant Codegen as Playwright AI Codegen
    participant Browser as Chromium Browser
    participant App as Your Web App

    You-&gt;&gt;Codegen: "Log in with admin credentials,&lt;br/&gt;verify 3 active projects on dashboard"
    
    Codegen-&gt;&gt;Browser: Navigate to /login
    Browser-&gt;&gt;App: GET /login
    App--&gt;&gt;Browser: Login page HTML
    
    Codegen-&gt;&gt;Codegen: Analyze DOM:&lt;br/&gt;- Find elements with labels "Email", "Password"&lt;br/&gt;- Find button with accessible name "Sign In"&lt;br/&gt;- Plan: Fill → Fill → Click → Assert
    
    Codegen-&gt;&gt;Browser: Fill "Email" field
    Codegen-&gt;&gt;Browser: Fill "Password" field
    Codegen-&gt;&gt;Browser: Click "Sign In"
    Browser-&gt;&gt;App: POST /login
    App--&gt;&gt;Browser: Redirect to /dashboard
    
    Codegen-&gt;&gt;Codegen: Analyze new page:&lt;br/&gt;- Title contains "Dashboard"?&lt;br/&gt;- Find "3 active projects" text&lt;br/&gt;- Generate assertion
    
    Codegen--&gt;&gt;You: ✅ Script generated:&lt;br/&gt;page.GetByLabel("Email").FillAsync(...)&lt;br/&gt;page.GetByRole(button).ClickAsync()&lt;br/&gt;Expect(page).ToHaveTitleAsync(/Dashboard/)&lt;br/&gt;Expect(page.GetByTestId("project-count")).ToHaveTextAsync("3")
</code></pre>

<h3 id="multi-language-output">Multi-Language Output</h3>

<p>AI codegen supports all Playwright languages. The same natural-language input generates idiomatic code for each:</p>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Command</th>
      <th>Generated assertion style</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C# / NUnit</strong></td>
      <td>Default on .NET projects</td>
      <td><code class="language-plaintext highlighter-rouge">await Expect(Page.GetByText("Success")).ToBeVisibleAsync();</code></td>
    </tr>
    <tr>
      <td><strong>Python / pytest</strong></td>
      <td><code class="language-plaintext highlighter-rouge">--ai --lang python</code></td>
      <td><code class="language-plaintext highlighter-rouge">expect(page.get_by_text("Success")).to_be_visible()</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">--ai --lang ts</code></td>
      <td><code class="language-plaintext highlighter-rouge">await expect(page.getByText("Success")).toBeVisible();</code></td>
    </tr>
    <tr>
      <td><strong>Java / JUnit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">--ai --lang java</code></td>
      <td><code class="language-plaintext highlighter-rouge">assertThat(page.getByText("Success")).isVisible();</code></td>
    </tr>
  </tbody>
</table>

<h2 id="step-2-from-requirements-document-to-full-test-suite">Step 2: From Requirements Document to Full Test Suite</h2>

<p>The real power of AI codegen emerges when you feed it a <strong>requirements document</strong> instead of one-off instructions. This is the workflow teased in the <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy</a> post — Phase 2 in practice.</p>

<h3 id="the-requirements-to-tests-pipeline">The Requirements-to-Tests Pipeline</h3>

<pre><code class="language-mermaid">flowchart LR
    A["📄 Requirements Doc&lt;br/&gt;.md / .txt / Confluence"] --&gt; B["✂️ Split into Scenarios&lt;br/&gt;(AI parses 'happy path',&lt;br/&gt;'edge case', 'error state')"]
    B --&gt; C["🔀 For Each Scenario"]
    C --&gt; D["🎬 playwright codegen --ai"]
    D --&gt; E["📜 Generated Test File"]
    C --&gt; F["🎬 playwright codegen --ai"]
    F --&gt; G["📜 Generated Test File"]
    C --&gt; H["🎬 playwright codegen --ai"]
    H --&gt; I["📜 Generated Test File"]
    E --&gt; J["🧪 Combined Test Suite"]
    G --&gt; J
    I --&gt; J
    J --&gt; K["✅ Ready for CI/CD"]
</code></pre>

<h3 id="practical-example">Practical Example</h3>

<p>Say you have this requirements snippet for an e-commerce checkout:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># Checkout Flow Requirements</span>

<span class="gu">## Happy Path</span>
<span class="p">-</span> User adds item to cart from product page
<span class="p">-</span> User proceeds to checkout
<span class="p">-</span> User enters valid shipping address
<span class="p">-</span> User selects credit card payment
<span class="p">-</span> User confirms order
<span class="p">-</span> System displays order confirmation with order number
<span class="p">-</span> System sends confirmation email

<span class="gu">## Edge Cases</span>
<span class="p">-</span> User tries to checkout with empty cart → show "Cart is empty" message
<span class="p">-</span> User enters invalid credit card → show inline validation error
<span class="p">-</span> User's session expires during checkout → redirect to login, preserve cart

<span class="gu">## Error States</span>
<span class="p">-</span> Payment gateway timeout → show "Payment processing, do not refresh" with retry
<span class="p">-</span> Inventory goes out of stock between add-to-cart and checkout → show "Item unavailable" with alternatives
</code></pre></div></div>

<p><strong>Feed each section separately</strong> to AI codegen for best results:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Happy path</span>
npx playwright codegen <span class="nt">--ai</span> https://your-store.com/product/123 <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Add the item to cart, proceed to checkout, fill valid shipping
  address, select credit card payment, confirm order, verify order
  confirmation appears with an order number."</span>

<span class="c"># Edge case: empty cart</span>
npx playwright codegen <span class="nt">--ai</span> https://your-store.com/checkout <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Navigate directly to /checkout with an empty cart. Verify
  the page shows 'Cart is empty' and the checkout button is disabled."</span>

<span class="c"># Edge case: invalid credit card</span>
npx playwright codegen <span class="nt">--ai</span> https://your-store.com/checkout <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Add an item to cart, go to checkout, enter credit card
  number '0000-0000-0000-0000', verify an inline error appears
  below the card field saying 'Invalid card number'."</span>

<span class="c"># Error state: payment timeout</span>
npx playwright codegen <span class="nt">--ai</span> https://your-store.com/checkout <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Complete checkout up to payment step, then simulate a
  payment gateway timeout (use the test card 4444-3333-2222-1111
  which triggers a timeout on this app). Verify 'Payment processing'
  message appears with a retry button."</span>
</code></pre></div></div>

<p>AI codegen generates a separate test file per scenario. Combine them into a single test class, add a <code class="language-plaintext highlighter-rouge">[Test]</code> attribute per method, and you have a complete checkout test suite — <strong>generated from the requirements doc</strong> in under 10 minutes.</p>

<h3 id="what-ai-codegen-gets-right-and-wrong">What AI Codegen Gets Right (and Wrong)</h3>

<table>
  <thead>
    <tr>
      <th>What it nails</th>
      <th>What needs human review</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Semantic locators (<code class="language-plaintext highlighter-rouge">GetByRole</code>, <code class="language-plaintext highlighter-rouge">GetByLabel</code>, <code class="language-plaintext highlighter-rouge">GetByTestId</code>)</td>
      <td>Business logic assertions (did the order <em>really</em> succeed?)</td>
    </tr>
    <tr>
      <td>Web-First Assertions with auto-retry</td>
      <td>Test data setup (creating test users, seeding the database)</td>
    </tr>
    <tr>
      <td>Error handling patterns (<code class="language-plaintext highlighter-rouge">Try/Catch</code>, timeout configuration)</td>
      <td>Test isolation (does test B depend on test A’s state?)</td>
    </tr>
    <tr>
      <td>Consistent naming conventions per language</td>
      <td>Edge cases the requirements doc didn’t mention</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">using</code> statements and imports</td>
      <td>Performance (did it generate 50 sequential tests when 10 parallel ones would work?)</td>
    </tr>
  </tbody>
</table>

<p><strong>The human’s job shifts from writing code to reviewing code.</strong> You spend 80% less time typing and 100% more time thinking about what could go wrong.</p>

<h2 id="step-3-refining-ai-generated-tests">Step 3: Refining AI-Generated Tests</h2>

<p>AI codegen gives you an 80% solution. Here’s how to get to 95%.</p>

<h3 id="add-test-data-setup">Add Test Data Setup</h3>

<p>AI codegen works against live pages. It doesn’t know how to seed your database. Add setup methods manually:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="n">SetUp</span><span class="p">]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Setup</span><span class="p">()</span>
<span class="p">{</span>
    <span class="c1">// AI can't generate this — you know your data model</span>
    <span class="k">await</span> <span class="n">ApiClient</span><span class="p">.</span><span class="nf">SeedAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">TestData</span>
    <span class="p">{</span>
        <span class="n">User</span> <span class="p">=</span> <span class="k">new</span> <span class="n">User</span> <span class="p">{</span> <span class="n">Email</span> <span class="p">=</span> <span class="s">"admin@example.com"</span><span class="p">,</span> <span class="n">Role</span> <span class="p">=</span> <span class="s">"Admin"</span> <span class="p">},</span>
        <span class="n">Projects</span> <span class="p">=</span> <span class="n">Enumerable</span><span class="p">.</span><span class="nf">Range</span><span class="p">(</span><span class="m">1</span><span class="p">,</span> <span class="m">5</span><span class="p">).</span><span class="nf">Select</span><span class="p">(</span><span class="n">i</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="n">Project</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">$"Project </span><span class="p">{</span><span class="n">i</span><span class="p">}</span><span class="s">"</span> <span class="p">}).</span><span class="nf">ToList</span><span class="p">()</span>
    <span class="p">});</span>

    <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/login"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="replace-hardcoded-values-with-test-parameters">Replace Hardcoded Values with Test Parameters</h3>

<p>AI codegen generates hardcoded values. Parameterize them:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ❌ AI-generated: hardcoded values</span>
<span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByLabel</span><span class="p">(</span><span class="s">"Email"</span><span class="p">).</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"admin@example.com"</span><span class="p">);</span>

<span class="c1">// ✅ After human refinement: parameterized</span>
<span class="p">[</span><span class="nf">TestCase</span><span class="p">(</span><span class="s">"admin@example.com"</span><span class="p">,</span> <span class="s">"SuperSecret123!"</span><span class="p">,</span> <span class="n">ExpectedResult</span> <span class="p">=</span> <span class="k">true</span><span class="p">)]</span>
<span class="p">[</span><span class="nf">TestCase</span><span class="p">(</span><span class="s">"user@example.com"</span><span class="p">,</span> <span class="s">"wrong-password"</span><span class="p">,</span> <span class="n">ExpectedResult</span> <span class="p">=</span> <span class="k">false</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">bool</span><span class="p">&gt;</span> <span class="nf">LoginFlow</span><span class="p">(</span><span class="kt">string</span> <span class="n">email</span><span class="p">,</span> <span class="kt">string</span> <span class="n">password</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByLabel</span><span class="p">(</span><span class="s">"Email"</span><span class="p">).</span><span class="nf">FillAsync</span><span class="p">(</span><span class="n">email</span><span class="p">);</span>
    <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByLabel</span><span class="p">(</span><span class="s">"Password"</span><span class="p">).</span><span class="nf">FillAsync</span><span class="p">(</span><span class="n">password</span><span class="p">);</span>
    <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByRole</span><span class="p">(</span><span class="n">AriaRole</span><span class="p">.</span><span class="n">Button</span><span class="p">,</span> <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"Sign In"</span> <span class="p">}).</span><span class="nf">ClickAsync</span><span class="p">();</span>
    
    <span class="k">return</span> <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GetByText</span><span class="p">(</span><span class="s">"Dashboard"</span><span class="p">).</span><span class="nf">IsVisibleAsync</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="add-visual-regression-checks">Add Visual Regression Checks</h3>

<p>AI codegen doesn’t generate visual assertions. Add them:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// After AI-generated checkout flow completes</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="nf">ToHaveScreenshotAsync</span><span class="p">(</span><span class="s">"checkout-confirmation.png"</span><span class="p">,</span> <span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">MaxDiffPixelRatio</span> <span class="p">=</span> <span class="m">0.01f</span>  <span class="c1">// Allow 1% pixel difference</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="step-4-ai-codegen--mcp--two-sides-of-the-same-coin">Step 4: AI Codegen + MCP — Two Sides of the Same Coin</h2>

<p>AI codegen and Playwright MCP (from the <a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">previous guide</a>) solve different problems:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>AI Codegen</th>
      <th>Playwright MCP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>What it does</strong></td>
      <td>Generates Playwright scripts you commit to your repo</td>
      <td>Lets AI agents control the browser in real time</td>
    </tr>
    <tr>
      <td><strong>Output</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.cs</code> / <code class="language-plaintext highlighter-rouge">.py</code> / <code class="language-plaintext highlighter-rouge">.ts</code> / <code class="language-plaintext highlighter-rouge">.java</code> files</td>
      <td>Browser actions + screenshots + network traces</td>
    </tr>
    <tr>
      <td><strong>Best for</strong></td>
      <td>CI/CD pipelines, regression suites, repeatable tests</td>
      <td>Exploratory testing, one-off audits, debugging</td>
    </tr>
    <tr>
      <td><strong>Determinism</strong></td>
      <td>Deterministic (same script, same result)</td>
      <td>Non-deterministic (AI decides next action)</td>
    </tr>
    <tr>
      <td><strong>Human review</strong></td>
      <td>Review once, run forever</td>
      <td>Review per session</td>
    </tr>
  </tbody>
</table>

<h3 id="the-combined-workflow">The Combined Workflow</h3>

<pre><code class="language-mermaid">flowchart TD
    A["📄 Requirements Document"] --&gt; B["🤖 AI Codegen&lt;br/&gt;Generate test scripts"]
    B --&gt; C["👤 Human Review&lt;br/&gt;Add setup, parameterize,&lt;br/&gt;add visual checks"]
    C --&gt; D["🧪 CI/CD Pipeline&lt;br/&gt;Run on every PR"]
    
    D --&gt; E{"Test failed?"}
    E --&gt;|Yes| F["🔍 Playwright MCP&lt;br/&gt;AI agent investigates&lt;br/&gt;Opens browser, inspects DOM,&lt;br/&gt;captures screenshots"]
    F --&gt; G["📊 Diagnostic Report&lt;br/&gt;Root cause + suggested fix"]
    G --&gt; C
    
    E --&gt;|No| H["✅ PR Approved"]
</code></pre>

<p>AI codegen creates the tests, MCP debugs them when they fail. Together, they form a <strong>self-improving test pipeline</strong> — the Phase 4 vision from the <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy</a> post.</p>

<h2 id="step-5-cicd-integration--generated-tests-in-your-pipeline">Step 5: CI/CD Integration — Generated Tests in Your Pipeline</h2>

<p>AI-generated tests slot into any CI/CD pipeline. Here’s a GitHub Actions example:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">AI-Generated E2E Tests</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">playwright-tests</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup .NET</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-dotnet@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">dotnet-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">8.0'</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Playwright browsers</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npx playwright install --with-deps chromium</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run AI-generated test suite</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">dotnet test tests/GeneratedE2E/ --filter "Category=Smoke"</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload Playwright trace on failure</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">failure()</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">playwright-trace</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">test-results/</span>
</code></pre></div></div>

<p>Key point: the generated tests use <code class="language-plaintext highlighter-rouge">GetByRole</code>, <code class="language-plaintext highlighter-rouge">GetByLabel</code>, and <code class="language-plaintext highlighter-rouge">GetByTestId</code> — locators that are <strong>resilient to DOM changes</strong>. Your CI/CD pipeline won’t break because a CSS class was renamed.</p>

<h2 id="where-existing-posts-fit">Where Existing Posts Fit</h2>

<p>This post completes the 2026 Playwright trilogy:</p>

<table>
  <thead>
    <tr>
      <th>Earlier post</th>
      <th>What it covered</th>
      <th>What this post adds</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">Playwright MCP + Multi-Agent (Jul 2026)</a></td>
      <td>Letting AI drive the browser via MCP, multi-agent orchestration</td>
      <td>AI <em>writing</em> the test scripts you commit to version control</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/frameworks/mastering-playwright-dotnet/">Playwright .NET Framework (Sep 2024)</a></td>
      <td>Manual Playwright setup: DI, Page Objects, Allure reports</td>
      <td>AI-generated Page Objects and test classes replace manual wiring</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy (Jun 2026)</a></td>
      <td>Phase 2: AI-assisted test generation with structured prompts</td>
      <td>Phase 2 in practice: <code class="language-plaintext highlighter-rouge">codegen --ai</code> as the implementation</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium 2026 Beginner’s Guide (Jul 2026)</a></td>
      <td>Selenium setup, BiDi, MCP server</td>
      <td>Playwright’s AI codegen is the capability Selenium doesn’t yet match — see <a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">BiDi vs CDP</a> for the architectural reasons why</td>
    </tr>
  </tbody>
</table>

<h2 id="when-to-use-ai-codegen-vs-alternatives">When to Use AI Codegen vs. Alternatives</h2>

<pre><code class="language-mermaid">flowchart TD
    START["🤔 I need Playwright tests"] --&gt; Q1{"Are the requirements&lt;br/&gt;well-documented?"}
    
    Q1 --&gt;|"Yes — I have a spec,&lt;br/&gt;PRD, or Confluence page"| AI["🤖 AI Codegen&lt;br/&gt;Feed requirements →&lt;br/&gt;generate suite →&lt;br/&gt;human review"]
    
    Q1 --&gt;|"No — I'm still&lt;br/&gt;figuring out the flow"| Q2{"Do I need to explore&lt;br/&gt;the app interactively?"}
    
    Q2 --&gt;|Yes| MCP["🧠 Playwright MCP&lt;br/&gt;AI agent drives browser,&lt;br/&gt;you observe + learn"]
    
    Q2 --&gt;|No| MANUAL["✍️ Traditional codegen&lt;br/&gt;Record clicks →&lt;br/&gt;refine selectors manually"]
    
    AI --&gt; REVIEW["👤 Human Review&lt;br/&gt;Add data setup,&lt;br/&gt;parameterize,&lt;br/&gt;visual checks"]
    MCP --&gt; DOC["📝 Document findings&lt;br/&gt;→ Feed to AI codegen&lt;br/&gt;for v2 of tests"]
    MANUAL --&gt; REVIEW
    
    REVIEW --&gt; CI["🧪 Commit to CI/CD"]
</code></pre>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://playwright.dev/docs/codegen">Playwright Codegen Documentation</a> — official guide to the test generator, including <code class="language-plaintext highlighter-rouge">--ai</code> mode</li>
  <li><a href="https://playwright.dev/dotnet/docs/intro">Playwright .NET API Reference</a> — C# API docs for the locator and assertion patterns used throughout</li>
  <li><a href="https://modelcontextprotocol.io/">Model Context Protocol Specification</a> — the MCP standard that enables the combined AI codegen + MCP debugging workflow in Step 4</li>
  <li><a href="https://github.com/angiejones/mcp-selenium">Angie Jones / mcp-selenium</a> — the original Selenium MCP server for comparison with Playwright’s native <code class="language-plaintext highlighter-rouge">@playwright/mcp</code></li>
</ol>

<h2 id="what-to-do-next">What to Do Next</h2>

<ol>
  <li><strong>Try basic AI codegen right now.</strong> Run <code class="language-plaintext highlighter-rouge">npx playwright codegen --ai</code> against any public website and type “Log in, verify the page title contains Dashboard, then click the first item in the list.” Watch the semantic locators appear in real time.</li>
  <li><strong>Feed a real requirements doc.</strong> Take a Confluence page or PRD you already have, split it into scenarios, and feed each section to <code class="language-plaintext highlighter-rouge">codegen --ai</code>. You’ll have a test suite in under 30 minutes.</li>
  <li><strong>Combine with MCP for debugging.</strong> When an AI-generated test fails in CI, spin up <a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">Playwright MCP</a> to investigate the failure interactively instead of staring at a trace file.</li>
  <li><strong>Compare to Selenium.</strong> Read the <a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">BiDi vs CDP comparison</a> to understand why Playwright’s native CDP architecture enables AI codegen while Selenium’s WebDriver layer makes it harder.</li>
  <li><strong>Subscribe to this blog’s <a href="/feed.xml">feed.xml</a></strong> — next up: self-healing test suites that automatically fix broken locators in CI/CD using the same intent-based AI that powers codegen.</li>
</ol>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/automation/tools/playwright-vs-selenium-2026/">Playwright vs Selenium in 2026 (Jun 2026)</a> — the speed, reliability, and ecosystem comparison that explains why Playwright leads on AI-native features like codegen. · <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation (Jul 2026)</a> — the story-mode article with §12 complex XPath &amp; CSS for SDETs (the locator patterns AI codegen generates that you should review before merging — SVG, computed indices, ARIA chains, iframe/shadow-DOM, modern CSS <code class="language-plaintext highlighter-rouge">:has()/</code>:is()/<code class="language-plaintext highlighter-rouge">:where()</code>)..</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="tools" /><category term="playwright" /><category term="ai-codegen" /><category term="test-generation" /><category term="natural-language" /><category term="beginners" /><category term="csharp" /><category term="java" /><category term="typescript" /><category term="javascript" /><category term="python" /><category term="ci-cd" /><summary type="html"><![CDATA[Playwright's codegen --ai writes tests from plain English. I still review everything — but I type about 80% less than I used to.]]></summary></entry><entry><title type="html">Automating Complex Interactions in 2026: Selenium BiDi vs. Playwright CDP</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/" rel="alternate" type="text/html" title="Automating Complex Interactions in 2026: Selenium BiDi vs. Playwright CDP" /><published>2026-07-16T00:00:00+00:00</published><updated>2026-07-16T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/"><![CDATA[<p>Two years ago, automating drag-and-drop meant writing JavaScript fallbacks because Selenium’s <code class="language-plaintext highlighter-rouge">Actions</code> class couldn’t keep up with custom web components. Today, both Selenium and Playwright have low-level browser protocol access — <strong>BiDi</strong> and <strong>CDP</strong> — that let you simulate interactions at the engine level, intercept network traffic, and even replay sessions with AI assistance.</p>

<p>This post compares the two approaches side-by-side for the three hardest interaction types: drag-and-drop, network interception, and AI-powered replay. If you read the <a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium 2026 guide</a> and the <a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">Playwright MCP guide</a>, this is the next step — going deeper into the protocol layer.</p>

<h2 id="how-bidi-and-cdp-actually-work">How BiDi and CDP Actually Work</h2>

<p>Before comparing code, let’s understand what’s happening under the hood.</p>

<pre><code class="language-mermaid">flowchart TD
    subgraph "Selenium + WebDriver BiDi"
        A1["🧪 Your Test Code"] --&gt;|"WebDriver BiDi\n(WebSocket)"| B1["🔧 Selenium Server"]
        B1 --&gt;|"BiDi → HTTP translation"| C1["🌐 Browser Driver\n(chromedriver / geckodriver)"]
        C1 --&gt;|"DevTools Protocol"| D1["🖥️ Browser"]
    end

    subgraph "Playwright + Native CDP"
        A2["🧪 Your Test Code"] --&gt;|"Native CDP\n(direct socket)"| D2["🖥️ Browser"]
    end

    style A1 fill:#0ea5c7,color:#fff
    style A2 fill:#34d399,color:#000
</code></pre>

<p>The critical difference: <strong>Selenium BiDi adds a translation layer</strong>. The browser speaks CDP natively, but Selenium wraps it in the WebDriver BiDi protocol — a WebSocket-based standard that all browser vendors agreed on. Playwright skips the translation and talks CDP directly.</p>

<table>
  <thead>
    <tr>
      <th>Characteristic</th>
      <th>Selenium BiDi</th>
      <th>Playwright CDP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Protocol</td>
      <td>WebDriver BiDi (W3C standard)</td>
      <td>Chrome DevTools Protocol (Chromium-native)</td>
    </tr>
    <tr>
      <td>Browser support</td>
      <td>Chrome, Edge, Firefox</td>
      <td>Chromium, Firefox, WebKit</td>
    </tr>
    <tr>
      <td>Setup</td>
      <td>Selenium Manager auto-downloads driver</td>
      <td>Playwright bundles browser binaries</td>
    </tr>
    <tr>
      <td>Network events</td>
      <td><code class="language-plaintext highlighter-rouge">NetworkResponseReceived</code> event</td>
      <td><code class="language-plaintext highlighter-rouge">page.route()</code> interceptor</td>
    </tr>
    <tr>
      <td>Low-level access</td>
      <td>BiDi log + network domains</td>
      <td>Full CDP surface (DOM, CSS, Performance, etc.)</td>
    </tr>
    <tr>
      <td>Learning curve</td>
      <td>Moderate — BiDi has fewer domains than CDP</td>
      <td>Steeper — CDP has 50+ domains</td>
    </tr>
  </tbody>
</table>

<p>The tradeoff: BiDi is simpler but less powerful. CDP is deeper but Chromium-only for the most advanced features.</p>

<h2 id="drag-and-drop-from-javascript-hacks-to-protocol-level-events">Drag-and-Drop: From JavaScript Hacks to Protocol-Level Events</h2>

<p>In the <a href="/techtalkwith-veeresh/automation/tools/drag-and-drop-csharp-selenium/">2024 drag-and-drop guide</a>, most methods relied on JavaScript fallbacks because Selenium’s <code class="language-plaintext highlighter-rouge">Actions</code> class couldn’t reliably trigger custom drag handlers. In 2026, both BiDi and CDP let you fire the raw <code class="language-plaintext highlighter-rouge">dragstart</code>, <code class="language-plaintext highlighter-rouge">dragover</code>, and <code class="language-plaintext highlighter-rouge">drop</code> events that the application is actually listening for.</p>

<h3 id="selenium-bidi-dispatch-input-events">Selenium BiDi: Dispatch Input Events</h3>

<p>BiDi’s input domain lets you synthesize pointer events. You describe the interaction as a sequence of low-level actions:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">OpenQA.Selenium</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">OpenQA.Selenium.Chrome</span><span class="p">;</span>

<span class="k">using</span> <span class="nn">IWebDriver</span> <span class="n">driver</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ChromeDriver</span><span class="p">();</span>
<span class="n">driver</span><span class="p">.</span><span class="nf">Navigate</span><span class="p">().</span><span class="nf">GoToUrl</span><span class="p">(</span><span class="s">"https://your-app.com/kanban"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">source</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">FindElement</span><span class="p">(</span><span class="n">By</span><span class="p">.</span><span class="nf">CssSelector</span><span class="p">(</span><span class="s">".card[draggable='true']"</span><span class="p">));</span>
<span class="kt">var</span> <span class="n">target</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">FindElement</span><span class="p">(</span><span class="n">By</span><span class="p">.</span><span class="nf">CssSelector</span><span class="p">(</span><span class="s">".column:nth-child(3)"</span><span class="p">));</span>

<span class="c1">// BiDi: use the BrowsingContext to dispatch low-level input events</span>
<span class="kt">var</span> <span class="n">browsingContext</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">GetBrowsingContext</span><span class="p">();</span>

<span class="c1">// Dispatch a pointer down on the source element</span>
<span class="k">await</span> <span class="n">browsingContext</span><span class="p">.</span><span class="nf">DispatchPointerEventAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">Type</span> <span class="p">=</span> <span class="n">PointerEventType</span><span class="p">.</span><span class="n">PointerDown</span><span class="p">,</span>
    <span class="n">X</span> <span class="p">=</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">X</span> <span class="p">+</span> <span class="n">source</span><span class="p">.</span><span class="n">Size</span><span class="p">.</span><span class="n">Width</span> <span class="p">/</span> <span class="m">2</span><span class="p">,</span>
    <span class="n">Y</span> <span class="p">=</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">Y</span> <span class="p">+</span> <span class="n">source</span><span class="p">.</span><span class="n">Size</span><span class="p">.</span><span class="n">Height</span> <span class="p">/</span> <span class="m">2</span>
<span class="p">});</span>

<span class="c1">// Move the pointer toward the target (multiple small moves for smooth animation)</span>
<span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="m">10</span><span class="p">;</span> <span class="n">i</span><span class="p">++)</span>
<span class="p">{</span>
    <span class="kt">double</span> <span class="n">progress</span> <span class="p">=</span> <span class="p">(</span><span class="n">i</span> <span class="p">+</span> <span class="m">1</span><span class="p">)</span> <span class="p">/</span> <span class="m">10.0</span><span class="p">;</span>
    <span class="k">await</span> <span class="n">browsingContext</span><span class="p">.</span><span class="nf">DispatchPointerEventAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">Type</span> <span class="p">=</span> <span class="n">PointerEventType</span><span class="p">.</span><span class="n">PointerMove</span><span class="p">,</span>
        <span class="n">X</span> <span class="p">=</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">X</span> <span class="p">+</span> <span class="p">(</span><span class="kt">int</span><span class="p">)((</span><span class="n">target</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">X</span> <span class="p">-</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">X</span><span class="p">)</span> <span class="p">*</span> <span class="n">progress</span><span class="p">),</span>
        <span class="n">Y</span> <span class="p">=</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">Y</span> <span class="p">+</span> <span class="p">(</span><span class="kt">int</span><span class="p">)((</span><span class="n">target</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">Y</span> <span class="p">-</span> <span class="n">source</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">Y</span><span class="p">)</span> <span class="p">*</span> <span class="n">progress</span><span class="p">)</span>
    <span class="p">});</span>
    <span class="k">await</span> <span class="n">Task</span><span class="p">.</span><span class="nf">Delay</span><span class="p">(</span><span class="m">30</span><span class="p">);</span> <span class="c1">// Small delay between moves for natural feel</span>
<span class="p">}</span>

<span class="c1">// Release on the target</span>
<span class="k">await</span> <span class="n">browsingContext</span><span class="p">.</span><span class="nf">DispatchPointerEventAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">Type</span> <span class="p">=</span> <span class="n">PointerEventType</span><span class="p">.</span><span class="n">PointerUp</span><span class="p">,</span>
    <span class="n">X</span> <span class="p">=</span> <span class="n">target</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">X</span> <span class="p">+</span> <span class="n">target</span><span class="p">.</span><span class="n">Size</span><span class="p">.</span><span class="n">Width</span> <span class="p">/</span> <span class="m">2</span><span class="p">,</span>
    <span class="n">Y</span> <span class="p">=</span> <span class="n">target</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="n">Y</span> <span class="p">+</span> <span class="n">target</span><span class="p">.</span><span class="n">Size</span><span class="p">.</span><span class="n">Height</span> <span class="p">/</span> <span class="m">2</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The advantage: BiDi fires actual <code class="language-plaintext highlighter-rouge">pointerdown</code>/<code class="language-plaintext highlighter-rouge">pointermove</code>/<code class="language-plaintext highlighter-rouge">pointerup</code> events that the browser dispatches natively. No JavaScript injection. No <code class="language-plaintext highlighter-rouge">DataTransfer</code> hacks. The application’s <code class="language-plaintext highlighter-rouge">dragstart</code> listener fires as if a real user dragged the element.</p>

<h3 id="playwright-cdp-route-and-simulate-at-the-protocol-level">Playwright CDP: Route and Simulate at the Protocol Level</h3>

<p>Playwright can go even deeper — it can intercept and modify the HTML5 drag events before they reach the page:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Playwright</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">playwright</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Playwright</span><span class="p">.</span><span class="nf">CreateAsync</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">browser</span> <span class="p">=</span> <span class="k">await</span> <span class="n">playwright</span><span class="p">.</span><span class="n">Chromium</span><span class="p">.</span><span class="nf">LaunchAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Headless</span> <span class="p">=</span> <span class="k">false</span> <span class="p">});</span>
<span class="kt">var</span> <span class="n">page</span> <span class="p">=</span> <span class="k">await</span> <span class="n">browser</span><span class="p">.</span><span class="nf">NewPageAsync</span><span class="p">();</span>

<span class="c1">// Playwright's DragAndDropAsync uses CDP under the hood:</span>
<span class="c1">// 1. Injects a DataTransfer into the drag event pipeline</span>
<span class="c1">// 2. Fires dragstart → dragover → drop → dragend natively</span>
<span class="c1">// 3. No JavaScript hacks, no coordinate math</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/kanban"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">DragAndDropAsync</span><span class="p">(</span><span class="s">".card[draggable='true']"</span><span class="p">,</span> <span class="s">".column:nth-child(3)"</span><span class="p">);</span>
</code></pre></div></div>

<p>Playwright’s <code class="language-plaintext highlighter-rouge">DragAndDropAsync</code> is a single call. Under the hood it uses CDP to:</p>
<ol>
  <li>Inject a <code class="language-plaintext highlighter-rouge">DataTransfer</code> into the drag event pipeline</li>
  <li>Fire <code class="language-plaintext highlighter-rouge">dragstart</code> with the correct payload</li>
  <li>Fire <code class="language-plaintext highlighter-rouge">dragover</code> on each element the cursor passes over</li>
  <li>Fire <code class="language-plaintext highlighter-rouge">drop</code> on the target</li>
  <li>Fire <code class="language-plaintext highlighter-rouge">dragend</code> to clean up</li>
</ol>

<pre><code class="language-mermaid">sequenceDiagram
    participant Test as Your Test
    participant BiDi as Selenium BiDi
    participant CDP as Playwright CDP
    participant App as Web Application

    Note over Test,App: Selenium BiDi — explicit pointer events
    Test-&gt;&gt;BiDi: DispatchPointerEvent (pointerdown)
    BiDi-&gt;&gt;App: pointerdown → dragstart
    Test-&gt;&gt;BiDi: DispatchPointerEvent ×10 (pointermove)
    BiDi-&gt;&gt;App: pointermove ×10 → dragover ×10
    Test-&gt;&gt;BiDi: DispatchPointerEvent (pointerup)
    BiDi-&gt;&gt;App: pointerup → drop → dragend
    BiDi--&gt;&gt;Test: ✅ Element moved

    Note over Test,App: Playwright CDP — one call, engine handles details
    Test-&gt;&gt;CDP: DragAndDropAsync(source, target)
    CDP-&gt;&gt;App: Inject DataTransfer + fire dragstart
    CDP-&gt;&gt;App: Calculate path + fire dragover on each element
    CDP-&gt;&gt;App: Fire drop on target
    CDP-&gt;&gt;App: Fire dragend
    CDP--&gt;&gt;Test: ✅ Element moved
</code></pre>

<h3 id="which-wins-for-drag-and-drop">Which Wins for Drag-and-Drop?</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Selenium BiDi</th>
      <th>Playwright CDP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>API simplicity</strong></td>
      <td>Explicit pointer events (more code)</td>
      <td><code class="language-plaintext highlighter-rouge">DragAndDropAsync()</code> (one line)</td>
    </tr>
    <tr>
      <td><strong>Reliability</strong></td>
      <td>Must calculate coordinates correctly</td>
      <td>Engine handles coordinate math</td>
    </tr>
    <tr>
      <td><strong>Debugging</strong></td>
      <td>Easy to step through each pointer event</td>
      <td>Black-box — one call, pass or fail</td>
    </tr>
    <tr>
      <td><strong>Browser support</strong></td>
      <td>Chrome, Edge, Firefox</td>
      <td>Chromium, Firefox, WebKit</td>
    </tr>
    <tr>
      <td><strong>Custom drag handlers</strong></td>
      <td>Fires native events — works with any handler</td>
      <td>Injects DataTransfer — compatible with HTML5 drag API</td>
    </tr>
  </tbody>
</table>

<p><strong>Winner: Playwright</strong> for simplicity. <strong>Selenium BiDi</strong> for debugging and Firefox support.</p>

<h2 id="network-interception-watch-modify-and-mock-api-calls">Network Interception: Watch, Modify, and Mock API Calls</h2>

<p>Both BiDi and CDP let you intercept network requests mid-flight. This is the superpower that eliminates the need for separate API mocking tools in many cases.</p>

<h3 id="selenium-bidi-network-event-subscription">Selenium BiDi: Network Event Subscription</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">driver</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ChromeDriver</span><span class="p">();</span>

<span class="c1">// Subscribe to network events BEFORE navigating</span>
<span class="kt">var</span> <span class="n">network</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">Manage</span><span class="p">().</span><span class="n">Network</span><span class="p">;</span>

<span class="n">network</span><span class="p">.</span><span class="n">NetworkRequestSent</span> <span class="p">+=</span> <span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="n">e</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"➡️ </span><span class="p">{</span><span class="n">e</span><span class="p">.</span><span class="n">RequestMethod</span><span class="p">}</span><span class="s"> </span><span class="p">{</span><span class="n">e</span><span class="p">.</span><span class="n">RequestUrl</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">};</span>

<span class="n">network</span><span class="p">.</span><span class="n">NetworkResponseReceived</span> <span class="p">+=</span> <span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="n">e</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">status</span> <span class="p">=</span> <span class="n">e</span><span class="p">.</span><span class="n">ResponseStatusCode</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">icon</span> <span class="p">=</span> <span class="n">status</span> <span class="k">switch</span>
    <span class="p">{</span>
        <span class="p">&gt;=</span> <span class="m">200</span> <span class="n">and</span> <span class="p">&lt;</span> <span class="m">300</span> <span class="p">=&gt;</span> <span class="s">"✅"</span><span class="p">,</span>
        <span class="p">&gt;=</span> <span class="m">400</span> <span class="n">and</span> <span class="p">&lt;</span> <span class="m">500</span> <span class="p">=&gt;</span> <span class="s">"⚠️"</span><span class="p">,</span>
        <span class="p">&gt;=</span> <span class="m">500</span> <span class="p">=&gt;</span> <span class="s">"❌"</span><span class="p">,</span>
        <span class="n">_</span> <span class="p">=&gt;</span> <span class="s">"ℹ️"</span>
    <span class="p">};</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">icon</span><span class="p">}</span><span class="s"> </span><span class="p">{</span><span class="n">status</span><span class="p">}</span><span class="s"> ← </span><span class="p">{</span><span class="n">e</span><span class="p">.</span><span class="n">ResponseUrl</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">};</span>

<span class="n">network</span><span class="p">.</span><span class="nf">StartMonitoring</span><span class="p">();</span>

<span class="n">driver</span><span class="p">.</span><span class="nf">Navigate</span><span class="p">().</span><span class="nf">GoToUrl</span><span class="p">(</span><span class="s">"https://your-app.com/dashboard"</span><span class="p">);</span>

<span class="c1">// After navigation, assert every API call succeeded</span>
<span class="kt">var</span> <span class="n">failedCalls</span> <span class="p">=</span> <span class="n">network</span><span class="p">.</span><span class="nf">GetReceivedResponses</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="n">r</span> <span class="p">=&gt;</span> <span class="n">r</span><span class="p">.</span><span class="n">ResponseStatusCode</span> <span class="p">&gt;=</span> <span class="m">400</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>

<span class="n">Assert</span><span class="p">.</span><span class="nf">IsEmpty</span><span class="p">(</span><span class="n">failedCalls</span><span class="p">,</span> 
    <span class="s">$"Found </span><span class="p">{</span><span class="n">failedCalls</span><span class="p">.</span><span class="n">Count</span><span class="p">}</span><span class="s"> failed API calls: "</span> <span class="p">+</span>
    <span class="kt">string</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="s">", "</span><span class="p">,</span> <span class="n">failedCalls</span><span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">c</span> <span class="p">=&gt;</span> <span class="s">$"</span><span class="p">{</span><span class="n">c</span><span class="p">.</span><span class="n">ResponseStatusCode</span><span class="p">}</span><span class="s"> </span><span class="p">{</span><span class="n">c</span><span class="p">.</span><span class="n">ResponseUrl</span><span class="p">}</span><span class="s">"</span><span class="p">)));</span>
</code></pre></div></div>

<p>BiDi’s network domain is event-driven: you subscribe to <code class="language-plaintext highlighter-rouge">NetworkRequestSent</code> and <code class="language-plaintext highlighter-rouge">NetworkResponseReceived</code>, and the browser pushes events to you in real time. No polling, no explicit waits.</p>

<h3 id="playwright-cdp-route-interception">Playwright CDP: Route Interception</h3>

<p>Playwright’s <code class="language-plaintext highlighter-rouge">page.RouteAsync()</code> is more powerful — you can intercept, modify, or mock responses mid-flight:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">page</span> <span class="p">=</span> <span class="k">await</span> <span class="n">browser</span><span class="p">.</span><span class="nf">NewPageAsync</span><span class="p">();</span>

<span class="c1">// Intercept ALL API calls to /api/</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">RouteAsync</span><span class="p">(</span><span class="s">"**/api/**"</span><span class="p">,</span> <span class="k">async</span> <span class="n">route</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">request</span> <span class="p">=</span> <span class="n">route</span><span class="p">.</span><span class="n">Request</span><span class="p">;</span>

    <span class="c1">// Option 1: Let it through but log</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"📡 </span><span class="p">{</span><span class="n">request</span><span class="p">.</span><span class="n">Method</span><span class="p">}</span><span class="s"> </span><span class="p">{</span><span class="n">request</span><span class="p">.</span><span class="n">Url</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

    <span class="c1">// Option 2: Mock the response</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">Url</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="s">"/api/slow-endpoint"</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">route</span><span class="p">.</span><span class="nf">FulfillAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
        <span class="p">{</span>
            <span class="n">Status</span> <span class="p">=</span> <span class="m">200</span><span class="p">,</span>
            <span class="n">ContentType</span> <span class="p">=</span> <span class="s">"application/json"</span><span class="p">,</span>
            <span class="n">Body</span> <span class="p">=</span> <span class="s">"""{ "</span><span class="n">mocked</span><span class="s">": true, "</span><span class="n">message</span><span class="s">": "</span><span class="n">This</span> <span class="n">response</span> <span class="n">was</span> <span class="n">intercepted</span> <span class="k">by</span> <span class="n">CDP</span><span class="s">" }"""</span>
        <span class="p">});</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Option 3: Let it pass through</span>
    <span class="k">await</span> <span class="n">route</span><span class="p">.</span><span class="nf">ContinueAsync</span><span class="p">();</span>
<span class="p">});</span>

<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/dashboard"</span><span class="p">);</span>

<span class="c1">// The slow endpoint now returns instantly with mocked data</span>
</code></pre></div></div>

<pre><code class="language-mermaid">flowchart LR
    A["🌐 Browser&lt;br/&gt;makes API call"] --&gt; B{"CDP Route&lt;br/&gt;Interceptor"}
    B --&gt;|"Pass through"| C["🔗 Real Backend"]
    B --&gt;|"Mock"| D["📦 Mock Response"]
    B --&gt;|"Modify"| E["✏️ Modified Response"]
    C --&gt; F["📄 Rendered in Page"]
    D --&gt; F
    E --&gt; F
</code></pre>

<h3 id="which-wins-for-network-interception">Which Wins for Network Interception?</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Selenium BiDi</th>
      <th>Playwright CDP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Event subscription</strong></td>
      <td>Simple event handlers</td>
      <td><code class="language-plaintext highlighter-rouge">page.RouteAsync()</code> with pattern matching</td>
    </tr>
    <tr>
      <td><strong>Mock responses</strong></td>
      <td>Not supported (read-only)</td>
      <td><code class="language-plaintext highlighter-rouge">route.FulfillAsync()</code> — full mock capability</td>
    </tr>
    <tr>
      <td><strong>Modify in-flight</strong></td>
      <td>Not supported</td>
      <td><code class="language-plaintext highlighter-rouge">route.ContinueAsync()</code> with modified headers/body</td>
    </tr>
    <tr>
      <td><strong>Real-time monitoring</strong></td>
      <td>Built-in — subscribe and forget</td>
      <td>Requires explicit route handlers</td>
    </tr>
    <tr>
      <td><strong>Use case</strong></td>
      <td>Passive monitoring, assertion on response codes</td>
      <td>Active interception, mocking, throttling</td>
    </tr>
  </tbody>
</table>

<p><strong>Winner: Playwright CDP</strong> for power and flexibility. <strong>Selenium BiDi</strong> for simple monitoring — the event subscription model is cleaner for “watch everything and report” scenarios.</p>

<h2 id="ai-powered-interaction-replay">AI-Powered Interaction Replay</h2>

<p>This is the 2026 differentiator. Both BiDi and CDP can record a user session and replay it through an AI agent that adapts to UI changes.</p>

<h3 id="how-it-works">How It Works</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. RECORD  →  User performs drag-and-drop manually in the browser
2. CAPTURE  →  BiDi/CDP records every event + DOM state at each step
3. DESCRIBE →  AI translates the recording into natural language:
              "Drag the card titled 'Fix login bug' from the 'Backlog'
               column to the 'In Progress' column"
4. REPLAY   →  AI agent executes the description, adapting to layout
               changes (the card moved 50px right? AI finds it by title)
5. HEAL     →  If replay fails, AI analyzes DOM diff and retries with
               an adjusted strategy
</code></pre></div></div>

<pre><code class="language-mermaid">flowchart TD
    A["👤 User performs&lt;br/&gt;drag-and-drop manually"] --&gt; B["📹 BiDi / CDP records&lt;br/&gt;every event + DOM snapshot"]
    B --&gt; C["🧠 AI translates recording&lt;br/&gt;into natural language intent"]
    C --&gt; D["🤖 AI agent replays intent&lt;br/&gt;via BiDi / CDP"]
    D --&gt; E{"Did it work?"}
    E --&gt;|Yes| F["✅ Test passed"]
    E --&gt;|No| G["🔍 AI analyzes DOM diff&lt;br/&gt;finds element by semantic role"]
    G --&gt; H["🔄 Retry with adjusted&lt;br/&gt;locator strategy"]
    H --&gt; E
</code></pre>

<h3 id="selenium-bidi--ai-replay">Selenium BiDi + AI Replay</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Step 1: Record a BiDi session</span>
<span class="kt">var</span> <span class="n">driver</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ChromeDriver</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">recorder</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">BiDiSessionRecorder</span><span class="p">(</span><span class="n">driver</span><span class="p">);</span>

<span class="n">recorder</span><span class="p">.</span><span class="nf">StartRecording</span><span class="p">();</span>
<span class="n">driver</span><span class="p">.</span><span class="nf">Navigate</span><span class="p">().</span><span class="nf">GoToUrl</span><span class="p">(</span><span class="s">"https://your-app.com/kanban"</span><span class="p">);</span>

<span class="c1">// User performs the drag-and-drop manually (or via Actions)</span>
<span class="c1">// ... manual interaction happens here ...</span>

<span class="n">recorder</span><span class="p">.</span><span class="nf">StopRecording</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">sessionTrace</span> <span class="p">=</span> <span class="n">recorder</span><span class="p">.</span><span class="nf">GetTrace</span><span class="p">();</span> <span class="c1">// JSON: every event + timestamps + DOM snapshots</span>

<span class="c1">// Step 2: AI translates trace → natural language intent</span>
<span class="kt">var</span> <span class="n">intent</span> <span class="p">=</span> <span class="k">await</span> <span class="n">AITranslator</span><span class="p">.</span><span class="nf">TraceToIntentAsync</span><span class="p">(</span><span class="n">sessionTrace</span><span class="p">);</span>
<span class="c1">// Output: "Drag the card with title 'Fix login bug' from column #1 to column #3"</span>

<span class="c1">// Step 3: Replay with self-healing</span>
<span class="kt">var</span> <span class="n">replayer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">BiDiSessionReplayer</span><span class="p">(</span><span class="n">driver</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">result</span> <span class="p">=</span> <span class="k">await</span> <span class="n">replayer</span><span class="p">.</span><span class="nf">ReplayWithHealingAsync</span><span class="p">(</span><span class="n">intent</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">Succeeded</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"✅ Replayed successfully after </span><span class="p">{</span><span class="n">result</span><span class="p">.</span><span class="n">Retries</span><span class="p">}</span><span class="s"> retries"</span><span class="p">);</span>
<span class="k">else</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"❌ Replay failed: </span><span class="p">{</span><span class="n">result</span><span class="p">.</span><span class="n">Error</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="playwright-cdp--ai-replay">Playwright CDP + AI Replay</h3>

<p>Playwright’s deeper CDP access gives the AI agent more information for self-healing:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright records a HAR file + DOM snapshots automatically</span>
<span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="k">await</span> <span class="n">browser</span><span class="p">.</span><span class="nf">NewContextAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">RecordHarPath</span> <span class="p">=</span> <span class="s">"session-trace.har"</span><span class="p">,</span>
    <span class="n">RecordHarMode</span> <span class="p">=</span> <span class="n">HarMode</span><span class="p">.</span><span class="n">Full</span>
<span class="p">});</span>

<span class="kt">var</span> <span class="n">page</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">NewPageAsync</span><span class="p">();</span>

<span class="c1">// CDP: inject a visual marker that tracks element positions</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">EvaluateAsync</span><span class="p">(</span><span class="s">@"() =&gt; {
    document.addEventListener('dragstart', e =&gt; {
        window.__dragSourceRect = e.target.getBoundingClientRect();
    });
    document.addEventListener('drop', e =&gt; {
        window.__dropTargetRect = e.target.getBoundingClientRect();
    });
}"</span><span class="p">);</span>

<span class="c1">// User performs interaction...</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/kanban"</span><span class="p">);</span>
<span class="c1">// ... manual drag-and-drop ...</span>

<span class="c1">// AI replay with CDP-enhanced healing</span>
<span class="kt">var</span> <span class="n">healer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">CDPHealer</span><span class="p">(</span><span class="n">page</span><span class="p">);</span>
<span class="k">await</span> <span class="n">healer</span><span class="p">.</span><span class="nf">ReplayFromHARAsync</span><span class="p">(</span><span class="s">"session-trace.har"</span><span class="p">,</span> <span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">SemanticFallback</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>  <span class="c1">// Find elements by ARIA role + text content</span>
    <span class="n">VisualDiff</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>        <span class="c1">// Compare screenshots to detect layout shifts</span>
    <span class="n">MaxRetries</span> <span class="p">=</span> <span class="m">3</span>
<span class="p">});</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>AI Replay Feature</th>
      <th>Selenium BiDi</th>
      <th>Playwright CDP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Event recording</strong></td>
      <td>WebSocket event log</td>
      <td>HAR + DOM snapshots</td>
    </tr>
    <tr>
      <td><strong>Semantic healing</strong></td>
      <td>By element attributes</td>
      <td>By ARIA role + text + visual position</td>
    </tr>
    <tr>
      <td><strong>Visual diff</strong></td>
      <td>Not built-in</td>
      <td>Screenshot comparison via CDP</td>
    </tr>
    <tr>
      <td><strong>AI translation</strong></td>
      <td>Community tools</td>
      <td>Playwright’s <code class="language-plaintext highlighter-rouge">codegen --ai</code> integration</td>
    </tr>
  </tbody>
</table>

<h2 id="when-to-use-which">When to Use Which</h2>

<pre><code class="language-mermaid">flowchart TD
    START["🤔 I need to automate&lt;br/&gt;a complex interaction"] --&gt; Q1{"Does it involve&lt;br/&gt;drag-and-drop?"}

    Q1 --&gt;|Yes| Q1a{"Do I need Firefox\nsupport?"}
    Q1a --&gt;|Yes| BIDI["🔷 Selenium BiDi&lt;br/&gt;explicit pointer events"]
    Q1a --&gt;|No| CDP1["🟢 Playwright CDP&lt;br/&gt;DragAndDropAsync()"]

    Q1 --&gt;|No| Q2{"Do I need to mock\nor modify API\nresponses?"}
    Q2 --&gt;|Yes| CDP2["🟢 Playwright CDP&lt;br/&gt;route.FulfillAsync()"]
    Q2 --&gt;|No| Q3{"Is this for passive\nnetwork monitoring\nin CI/CD?"}
    Q3 --&gt;|Yes| BIDI_MON["🔷 Selenium BiDi&lt;br/&gt;event subscription"]
    Q3 --&gt;|No| Q4{"Do I need AI-powered\ninteraction replay\nwith self-healing?"}
    Q4 --&gt;|Yes| CDP3["🟢 Playwright CDP&lt;br/&gt;HAR + visual diff"]
    Q4 --&gt;|No| EITHER["🤷 Either works —&lt;br/&gt;pick your ecosystem"]
</code></pre>

<p><strong>Rule of thumb:</strong></p>
<ul>
  <li><strong>Playwright CDP</strong> for Chromium-first teams that want maximum power (mock, modify, replay with visual diff)</li>
  <li><strong>Selenium BiDi</strong> for cross-browser teams that need Firefox and prefer explicit control over each event</li>
</ul>

<h2 id="where-existing-posts-fit">Where Existing Posts Fit</h2>

<table>
  <thead>
    <tr>
      <th>Earlier post</th>
      <th>What it covered</th>
      <th>What changed by 2026</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/drag-and-drop-csharp-selenium/">Drag-and-Drop in C# Selenium (Aug 2024)</a></td>
      <td>8 methods using <code class="language-plaintext highlighter-rouge">Actions</code> + JavaScript fallbacks</td>
      <td>BiDi fires native pointer events — no JS injection, no <code class="language-plaintext highlighter-rouge">DataTransfer</code> hacks</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium 2026 Beginner’s Guide (Jul 2026)</a></td>
      <td>WebDriver BiDi basics, MCP setup, Relative Locators</td>
      <td>This post goes deeper into BiDi’s input and network domains</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/">Playwright MCP + Multi-Agent (Jul 2026)</a></td>
      <td>MCP server, multi-agent pattern, Web-First Assertions</td>
      <td>CDP gives the Explorer agent network-level visibility; the Validator agent uses CDP route interception</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/playwright-vs-selenium-2026/">Playwright vs Selenium in 2026 (Jun 2026)</a></td>
      <td>Speed, reliability, multi-browser comparison</td>
      <td>Now with protocol-level comparison: BiDi vs CDP for complex interactions</td>
    </tr>
  </tbody>
</table>

<h2 id="multi-language-quick-reference">Multi-Language Quick Reference</h2>

<p>This post used C# examples. Here’s the equivalent syntax in <strong>Java</strong>, <strong>TypeScript</strong>, <strong>JavaScript</strong>, and <strong>Python</strong> for key BiDi and CDP operations:</p>

<h3 id="selenium-bidi--drag-and-drop">Selenium BiDi — Drag-and-Drop</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Pointer event dispatch</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">browsingContext.DispatchPointerEventAsync(new() { Type = PointerEventType.PointerDown, X = 100, Y = 200 })</code></td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">browsingContext.dispatchPointerEvent(new PointerEvent(PointerEventType.POINTER_DOWN, 100, 200))</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await browsingContext.dispatchPointerEvent({ type: 'pointerDown', x: 100, y: 200 })</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await browsingContext.dispatchPointerEvent({ type: 'pointerDown', x: 100, y: 200 })</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await browsing_context.dispatch_pointer_event(type="pointerDown", x=100, y=200)</code></td>
    </tr>
  </tbody>
</table>

<h3 id="selenium-bidi--network-monitoring">Selenium BiDi — Network Monitoring</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Event subscription pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">network.NetworkResponseReceived += (_, e) =&gt; { if (e.ResponseStatusCode &gt;= 400) ... }</code></td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">network.onNetworkResponseReceived(response -&gt; { if (response.getResponseStatusCode() &gt;= 400) ... })</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">network.on('networkResponseReceived', (response) =&gt; { if (response.statusCode &gt;= 400) ... })</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">network.on('networkResponseReceived', (response) =&gt; { if (response.statusCode &gt;= 400) ... })</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">network.on_network_response_received(lambda response: ... if response.status_code &gt;= 400)</code></td>
    </tr>
  </tbody>
</table>

<h3 id="playwright-cdp--drag-and-drop">Playwright CDP — Drag-and-Drop</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>One-line drag-and-drop</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.DragAndDropAsync(".source", ".target");</code></td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.dragAndDrop(".source", ".target");</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.dragAndDrop('.source', '.target');</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.dragAndDrop('.source', '.target');</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.drag_and_drop(".source", ".target")</code></td>
    </tr>
  </tbody>
</table>

<h3 id="playwright-cdp--route-interception">Playwright CDP — Route Interception</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Route mock pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.RouteAsync("**/api/**", async route =&gt; { await route.FulfillAsync(new() { Status = 200, Body = mockJson }); })</code></td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.route("**/api/**", route -&gt; route.fulfill(new Route.FulfillOptions().setStatus(200).setBody(mockJson)))</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.route('**/api/**', async route =&gt; { await route.fulfill({ status: 200, body: mockJson }) })</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.route('**/api/**', async route =&gt; { await route.fulfill({ status: 200, body: mockJson }) })</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await page.route("**/api/**", lambda route: route.fulfill(status=200, body=mock_json))</code></td>
    </tr>
  </tbody>
</table>

<h3 id="playwright--browser-launch">Playwright — Browser Launch</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Launch pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C#</strong></td>
      <td><code class="language-plaintext highlighter-rouge">var browser = await playwright.Chromium.LaunchAsync(new() { Headless = false })</code></td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td><code class="language-plaintext highlighter-rouge">Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false))</code></td>
    </tr>
    <tr>
      <td><strong>TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const browser = await playwright.chromium.launch({ headless: false })</code></td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">const browser = await playwright.chromium.launch({ headless: false })</code></td>
    </tr>
    <tr>
      <td><strong>Python</strong></td>
      <td><code class="language-plaintext highlighter-rouge">browser = await playwright.chromium.launch(headless=False)</code></td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>TypeScript vs JavaScript:</strong> Playwright’s TypeScript API uses the same syntax as JavaScript for most operations — the difference is type safety (<code class="language-plaintext highlighter-rouge">const browser: Browser</code>). Both benefit from Playwright’s auto-complete in VS Code.</p>
</blockquote>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://w3c.github.io/webdriver-bidi/">WebDriver BiDi W3C Specification</a> — the W3C standard for bidirectional browser automation</li>
  <li><a href="https://chromedevtools.github.io/devtools-protocol/">Chrome DevTools Protocol Documentation</a> — the CDP surface Playwright accesses natively and Selenium accesses via BiDi translation</li>
  <li><a href="https://playwright.dev/docs/api/class-cdpsession">Playwright CDP Session API</a> — official docs for the CDP session used in drag-and-drop and network interception examples</li>
  <li><a href="https://www.selenium.dev/documentation/webdriver/bidi/">Selenium WebDriver BiDi Guide</a> — official BiDi documentation including network and input domains</li>
</ol>

<h2 id="what-to-do-next">What to Do Next</h2>

<ol>
  <li><strong>Try the drag-and-drop examples.</strong> Take any drag-based UI (Kanban, file upload, sortable list) and test both the BiDi pointer-event approach and Playwright’s <code class="language-plaintext highlighter-rouge">DragAndDropAsync()</code>. See which one handles your app’s custom drag handlers.</li>
  <li><strong>Set up network monitoring in CI.</strong> Subscribe to BiDi’s <code class="language-plaintext highlighter-rouge">NetworkResponseReceived</code> in your existing Selenium suite. One event handler, and you’ll catch every 500 error your UI tests were silently ignoring.</li>
  <li><strong>Experiment with AI replay.</strong> Record a manual drag-and-drop session in Playwright (HAR recording is built-in), then ask an LLM to describe what happened in natural language. You’ve just built a self-documenting test.</li>
  <li><strong>Subscribe to this blog’s <a href="/feed.xml">feed.xml</a></strong> — next up: a deep-dive on self-healing locators and how AI can find elements by their semantic role when CSS selectors break.</li>
</ol>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy: From Copilot to Multi-Agent Orchestration (Jun 2026)</a> — the overarching thesis on multi-agent QA systems, including the self-healing locator pattern referenced above. · <a href="/techtalkwith-veeresh/automation/best-practices/self-healing-test-suites/">Self-Healing Test Suites (Jul 2026)</a> — full implementation: Java SemanticHealer, CDP accessibility tree, DOM diff, CI/CD healing log. · <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation (Jul 2026)</a> — the story-mode article whose §12 maps the complex XPath &amp; CSS patterns you ship selectors around (SVG namespace handling, computed indices, role/state ARIA chains, iframe/shadow-DOM piercing matrix, modern CSS Level 4 selectors, decision flowchart).</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="tools" /><category term="selenium" /><category term="playwright" /><category term="bidi" /><category term="cdp" /><category term="drag-and-drop" /><category term="network-interception" /><category term="ai-testing" /><category term="beginners" /><category term="csharp" /><category term="java" /><category term="typescript" /><category term="javascript" /><category term="python" /><summary type="html"><![CDATA[Selenium BiDi vs Playwright CDP for drag-drop and network sniffing. Protocol nerd stuff, explained at a whiteboard — not from a spec PDF.]]></summary></entry><entry><title type="html">Playwright MCP + Multi-Agent Testing in 2026: A Beginner’s Guide</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/" rel="alternate" type="text/html" title="Playwright MCP + Multi-Agent Testing in 2026: A Beginner’s Guide" /><published>2026-07-15T00:00:00+00:00</published><updated>2026-07-15T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/playwright-mcp-multi-agent-testing/"><![CDATA[<p>In the <a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">previous guide</a>, you learned how to set up Selenium with MCP and let an AI agent drive your browser. Now we go a step further: <strong>Playwright</strong> — the browser automation engine that was built for the modern web — plus <strong>multi-agent orchestration</strong>, where two or more AI agents collaborate on the same test run.</p>

<p>If Selenium + MCP is like handing one expert a remote control, Playwright + multi-agent is like giving a <strong>team</strong> of experts simultaneous access to the same browser session, each watching a different layer of your app from network calls to visual snapshots to accessibility violations.</p>

<p>This guide picks up where the Selenium post left off. No prior Playwright experience needed.</p>

<h2 id="what-makes-playwright-different-in-2026">What Makes Playwright Different in 2026</h2>

<p>Playwright was already faster than Selenium because it talks to the browser directly through the DevTools Protocol — no WebDriver middleman. In 2026, the gap is even wider:</p>

<table>
  <thead>
    <tr>
      <th>Capability</th>
      <th>Playwright (v1.50+)</th>
      <th>Selenium 4 + BiDi</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Browser communication</td>
      <td><strong>Native CDP</strong> — direct browser socket</td>
      <td>WebDriver BiDi — WebSocket bridge</td>
    </tr>
    <tr>
      <td>Auto-waiting</td>
      <td><strong>Built into every action</strong> — <code class="language-plaintext highlighter-rouge">page.ClickAsync()</code> waits for clickable state automatically</td>
      <td>Manual <code class="language-plaintext highlighter-rouge">WebDriverWait</code> or BiDi event subscription</td>
    </tr>
    <tr>
      <td>MCP server</td>
      <td><strong><code class="language-plaintext highlighter-rouge">@playwright/mcp</code></strong> — official, zero-config</td>
      <td><code class="language-plaintext highlighter-rouge">selenium-mcp</code> — community-driven</td>
    </tr>
    <tr>
      <td>Multi-browser</td>
      <td>Chromium + Firefox + <strong>WebKit</strong> (bundled)</td>
      <td>Chrome/Edge/Firefox, no WebKit</td>
    </tr>
    <tr>
      <td>Mobile emulation</td>
      <td>Built-in viewport + geolocation + touch</td>
      <td>Requires Appium</td>
    </tr>
    <tr>
      <td>AI codegen</td>
      <td><code class="language-plaintext highlighter-rouge">npx playwright codegen --ai</code> — natural language to script</td>
      <td>Not available</td>
    </tr>
    <tr>
      <td>Assertions</td>
      <td><strong>Web-First Assertions</strong> — auto-retry, no manual waits</td>
      <td>Standard test-framework asserts</td>
    </tr>
  </tbody>
</table>

<p>The biggest beginner win: Playwright <strong>auto-waits</strong>. You never write <code class="language-plaintext highlighter-rouge">Thread.Sleep(3000)</code> or <code class="language-plaintext highlighter-rouge">WebDriverWait</code>. The engine pauses until the element is ready — clickable, visible, stable — and only then proceeds.</p>

<h2 id="architecture-overview">Architecture Overview</h2>

<p>Here’s how the pieces fit together when you add MCP and multi-agent orchestration on top of Playwright:</p>

<pre><code class="language-mermaid">flowchart TD
    A["👤 You (the tester)"] --&gt; B["💬 Natural Language&lt;br/&gt;'Check the checkout flow&lt;br/&gt;and validate all API calls return 200'"]

    B --&gt; C["🧠 Supervisor Agent&lt;br/&gt;(Claude / Copilot)"]

    C --&gt;|MCP Protocol| D["🔌 Playwright MCP Server&lt;br/&gt;@playwright/mcp"]
    C --&gt;|MCP Protocol| E["📡 API Validator Agent&lt;br/&gt;(parallel worker)"]

    D --&gt;|Native CDP| F["🌐 Browser&lt;br/&gt;(Chromium / Firefox / WebKit)"]
    E --&gt;|HTTP| G["🔗 Backend APIs"]

    F --&gt;|Screenshots + DOM + HAR| D
    G --&gt;|JSON responses| E

    D --&gt;|"✅ UI: Checkout button clicked&lt;br/&gt;📸 Screenshot captured"| C
    E --&gt;|"✅ API: /cart returned 200&lt;br/&gt;⚠️ /checkout returned 422"| C

    C --&gt;|"📊 Combined Report:&lt;br/&gt;UI passed, API validation error at /checkout"| A
</code></pre>

<p>The Supervisor agent delegates to two workers simultaneously: the <strong>Playwright MCP server</strong> drives the browser while the <strong>API Validator</strong> watches every network call. If the UI looks right but an API returns an error, you catch it immediately — no separate API test suite needed.</p>

<h2 id="step-1-install-playwright-60-seconds">Step 1: Install Playwright (60 Seconds)</h2>

<p>Playwright bundles its own browser binaries. One command installs everything.</p>

<p><strong>.NET / C#:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new nunit <span class="nt">-n</span> MyFirstPlaywrightTest
<span class="nb">cd </span>MyFirstPlaywrightTest
dotnet add package Microsoft.Playwright.NUnit
dotnet build
playwright <span class="nb">install</span>
</code></pre></div></div>

<p><strong>Python:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>playwright
playwright <span class="nb">install</span>
</code></pre></div></div>

<p><strong>JavaScript / Node.js:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm init playwright@latest
<span class="c"># Select "TypeScript" or "JavaScript" when prompted</span>
</code></pre></div></div>

<p>That’s it. No WebDriver binaries, no <code class="language-plaintext highlighter-rouge">PATH</code> configuration, no <code class="language-plaintext highlighter-rouge">chromedriver.exe</code> version mismatch. Playwright downloads the exact browser versions it expects into a local cache.</p>

<h2 id="step-2-your-first-test--web-first-assertions">Step 2: Your First Test — Web-First Assertions</h2>

<p>Create a test file. Here’s the C# version (Python and JS versions follow the same pattern):</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Playwright.NUnit</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">System.Threading.Tasks</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">FirstTest</span> <span class="p">:</span> <span class="n">PageTest</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">Test</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">HelloPlaywright</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="c1">// Navigate — Playwright auto-waits for the page to load</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://www.google.com"</span><span class="p">);</span>

        <span class="c1">// Type into the search box — auto-waits for the element to be visible</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"textarea[name='q']"</span><span class="p">,</span> <span class="s">"Playwright MCP 2026"</span><span class="p">);</span>

        <span class="c1">// Press Enter</span>
        <span class="k">await</span> <span class="n">Page</span><span class="p">.</span><span class="nf">PressAsync</span><span class="p">(</span><span class="s">"textarea[name='q']"</span><span class="p">,</span> <span class="s">"Enter"</span><span class="p">);</span>

        <span class="c1">// Web-First Assertion: auto-retries until the title matches or times out</span>
        <span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="nf">ToHaveTitleAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">System</span><span class="p">.</span><span class="n">Text</span><span class="p">.</span><span class="n">RegularExpressions</span><span class="p">.</span><span class="nf">Regex</span><span class="p">(</span><span class="s">"Playwright"</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<pre><code class="language-mermaid">sequenceDiagram
    participant Test as Your Test Code
    participant PW as Playwright Engine
    participant Browser as Chromium Browser

    Test-&gt;&gt;PW: Page.GotoAsync("google.com")
    PW-&gt;&gt;Browser: Navigate
    Browser--&gt;&gt;PW: Page loaded (DOMContentLoaded)
    PW--&gt;&gt;Test: Ready

    Test-&gt;&gt;PW: Page.FillAsync("textarea[name='q']", "...")
    PW-&gt;&gt;PW: Auto-wait: is the textarea visible? stable? enabled?
    PW-&gt;&gt;Browser: Type text
    Browser--&gt;&gt;PW: Text filled
    PW--&gt;&gt;Test: Done

    Test-&gt;&gt;PW: Expect(Page).ToHaveTitleAsync(/Playwright/)
    PW-&gt;&gt;Browser: Get page title
    Browser--&gt;&gt;PW: "Playwright MCP 2026 - Google Search"
    PW-&gt;&gt;PW: Assert: does title match /Playwright/?
    PW--&gt;&gt;Test: ✅ PASS
</code></pre>

<p><strong>Key takeaway:</strong> you wrote zero wait logic. <code class="language-plaintext highlighter-rouge">FillAsync</code> waited for the element to be visible. <code class="language-plaintext highlighter-rouge">Expect</code> auto-retried until the condition was true. This is the Playwright baseline — no flaky tests, no manual timeout tuning.</p>

<h2 id="step-3-web-first-assertions--why-you-never-write-threadsleep-again">Step 3: Web-First Assertions — Why You Never Write <code class="language-plaintext highlighter-rouge">Thread.Sleep</code> Again</h2>

<p>In traditional Selenium, you spend 30% of your test code on waits:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ❌ Old way: guess how long to wait</span>
<span class="n">Thread</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="m">3000</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">text</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">FindElement</span><span class="p">(</span><span class="n">By</span><span class="p">.</span><span class="nf">ClassName</span><span class="p">(</span><span class="s">"success-message"</span><span class="p">)).</span><span class="n">Text</span><span class="p">;</span>
<span class="n">Assert</span><span class="p">.</span><span class="nf">AreEqual</span><span class="p">(</span><span class="s">"Order confirmed"</span><span class="p">,</span> <span class="n">text</span><span class="p">);</span>
</code></pre></div></div>

<p>Playwright’s Web-First Assertions have <strong>built-in auto-retry</strong>. You describe the <em>expected state</em>, and Playwright polls the browser until that state is true (or a 5-second default timeout expires):</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ✅ Playwright way: describe the outcome, let the engine wait</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="nf">Locator</span><span class="p">(</span><span class="s">".success-message"</span><span class="p">)).</span><span class="nf">ToHaveTextAsync</span><span class="p">(</span><span class="s">"Order confirmed"</span><span class="p">);</span>

<span class="c1">// More Web-First assertion examples:</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="nf">Locator</span><span class="p">(</span><span class="s">"button#submit"</span><span class="p">)).</span><span class="nf">ToBeEnabledAsync</span><span class="p">();</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="nf">Locator</span><span class="p">(</span><span class="s">".error-banner"</span><span class="p">)).</span><span class="nf">ToBeVisibleAsync</span><span class="p">();</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="nf">Locator</span><span class="p">(</span><span class="s">".cart-count"</span><span class="p">)).</span><span class="nf">ToContainTextAsync</span><span class="p">(</span><span class="s">"3"</span><span class="p">);</span>
<span class="k">await</span> <span class="nf">Expect</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="nf">Locator</span><span class="p">(</span><span class="s">"input#email"</span><span class="p">)).</span><span class="nf">ToHaveValueAsync</span><span class="p">(</span><span class="s">"user@example.com"</span><span class="p">);</span>
</code></pre></div></div>

<p>Each assertion polls the browser every ~100ms for up to 5 seconds. If the condition becomes true at 300ms, it passes immediately — no wasted time.</p>

<h3 id="multi-language-assertion-patterns">Multi-Language Assertion Patterns</h3>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Web-First Assertion Syntax</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>C# / NUnit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await Expect(Page.Locator(".msg")).ToHaveTextAsync("Done");</code></td>
    </tr>
    <tr>
      <td><strong>Python / pytest</strong></td>
      <td><code class="language-plaintext highlighter-rouge">expect(page.locator(".msg")).to_have_text("Done")</code></td>
    </tr>
    <tr>
      <td><strong>JS / TypeScript</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await expect(page.locator(".msg")).toHaveText("Done");</code></td>
    </tr>
  </tbody>
</table>

<p>All three follow the same pattern: <code class="language-plaintext highlighter-rouge">expect(locator).&lt;condition&gt;(expectedValue)</code>. The auto-retry logic is identical across languages.</p>

<h2 id="step-4-playwright-mcp-server--let-ai-drive-the-browser">Step 4: Playwright MCP Server — Let AI Drive the Browser</h2>

<p>This is the step that makes Playwright feel like the future. <strong>MCP (Model Context Protocol)</strong> turns your AI agent into a browser operator.</p>

<h3 id="how-playwright-mcp-differs-from-selenium-mcp">How Playwright MCP Differs from Selenium MCP</h3>

<p>In the <a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium 2026 guide</a>, you set up <code class="language-plaintext highlighter-rouge">selenium-mcp</code> — a Python package that translates AI commands into WebDriver calls. Playwright MCP is <strong>simpler and faster</strong>:</p>

<ul>
  <li><strong>Zero-config install</strong> — one npm package, no Python dependency</li>
  <li><strong>Native CDP</strong> — no WebDriver protocol overhead</li>
  <li><strong>Full Playwright surface</strong> — network interception, HAR recording, mobile emulation, visual comparison, all exposed as MCP tools</li>
  <li><strong>Auto-wait baked in</strong> — the AI says “click the login button” and Playwright handles timing</li>
</ul>

<h3 id="setup-3-minutes">Setup (3 Minutes)</h3>

<p><strong>1. Install the Playwright MCP server:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @playwright/mcp
</code></pre></div></div>

<p><strong>2. Register it with Claude Desktop.</strong></p>

<p>Open your Claude Desktop config file:</p>
<ul>
  <li><strong>Windows:</strong> <code class="language-plaintext highlighter-rouge">%APPDATA%\Claude\claude_desktop_config.json</code></li>
  <li><strong>macOS:</strong> <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Claude/claude_desktop_config.json</code></li>
  <li><strong>Linux:</strong> <code class="language-plaintext highlighter-rouge">~/.config/Claude/claude_desktop_config.json</code></li>
</ul>

<p>Add the Playwright MCP entry:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"playwright"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npx"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-y"</span><span class="p">,</span><span class="w"> </span><span class="s2">"@playwright/mcp"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"PLAYWRIGHT_HEADLESS"</span><span class="p">:</span><span class="w"> </span><span class="s2">"false"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>3. Restart Claude Desktop. You’ll see a new tool icon for Playwright.</strong> 🔌</p>

<p><strong>4. Start talking to your browser:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You:    "Go to https://the-internet.herokuapp.com/add_remove_elements/,
         click 'Add Element' three times, then click 'Delete' on the
         second button. How many buttons are left?"

Claude: [Opens Chromium, navigates, clicks Add Element ×3, clicks
         Delete on button #2]
        "There are 2 buttons remaining. Screenshot attached."
</code></pre></div></div>

<pre><code class="language-mermaid">flowchart LR
    subgraph "Your Machine"
        A["💬 'Add 3 elements, delete the 2nd, count remaining'"] --&gt; B["Claude Desktop"]
        B &lt;--&gt;|MCP| C["@playwright/mcp"]
        C &lt;--&gt;|Native CDP| D["Chromium Browser"]
        D --&gt; E["🌐 Target Website"]
    end
    F["📸 Screenshot: 2 buttons remain"] --&gt; B
</code></pre>

<h3 id="what-the-playwright-mcp-server-exposes">What the Playwright MCP Server Exposes</h3>

<p>The AI agent can call these tools directly — you don’t write the code:</p>

<table>
  <thead>
    <tr>
      <th>MCP Tool</th>
      <th>What it does</th>
      <th>Natural-language equivalent</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_navigate</code></td>
      <td>Navigate to a URL</td>
      <td>“Go to example.com”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_click</code></td>
      <td>Click an element</td>
      <td>“Click the Submit button”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_type</code></td>
      <td>Type into a field</td>
      <td>“Enter ‘test@example.com’ into the email field”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_snapshot</code></td>
      <td>Accessibility tree snapshot</td>
      <td>“What’s on the page right now?”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_take_screenshot</code></td>
      <td>Capture full-page screenshot</td>
      <td>“Take a screenshot”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_network_requests</code></td>
      <td>List all network requests</td>
      <td>“Show me every API call the page made”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_console_messages</code></td>
      <td>Read console logs</td>
      <td>“Are there any JavaScript errors?”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_evaluate</code></td>
      <td>Run arbitrary JS in the page</td>
      <td>“What’s the value of <code class="language-plaintext highlighter-rouge">window.__STATE__</code>?”</td>
    </tr>
  </tbody>
</table>

<h2 id="step-5-multi-agent-testing--two-agents-one-test-run">Step 5: Multi-Agent Testing — Two Agents, One Test Run</h2>

<p>This is where Playwright in 2026 surpasses everything else. <strong>Multi-agent testing</strong> means you run two or more AI agents in parallel, each watching a different layer of your application, coordinated by a single Supervisor.</p>

<h3 id="the-supervisor-worker-pattern">The Supervisor-Worker Pattern</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ┌─────────────────────────────────────┐
 │         Supervisor Agent            │
 │  "Test the checkout flow and        │
 │   validate all API responses."      │
 └──────────┬──────────────┬───────────┘
            │              │
      ┌─────▼─────┐  ┌─────▼──────────┐
      │  Explorer │  │  API Validator  │
      │  Agent    │  │  Agent          │
      │ (Playwright│  │ (Network        │
      │  MCP)     │  │  Inspector)     │
      └─────┬─────┘  └─────┬──────────┘
            │              │
   Clicks buttons,    Intercepts every
   fills forms,       XHR/fetch call,
   takes screenshots  validates status
            │         codes + payloads
            │              │
      ┌─────▼──────────────▼─────┐
      │     Combined Report      │
      │  ✅ UI: All steps passed │
      │  ⚠️ API: /checkout 422  │
      └──────────────────────────┘
</code></pre></div></div>

<p>Here’s what happens step-by-step:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant You
    participant Supervisor as Supervisor Agent
    participant Explorer as Explorer Agent&lt;br/&gt;(Playwright MCP)
    participant Validator as API Validator Agent
    participant Browser as Chromium Browser
    participant Backend as Backend APIs

    You-&gt;&gt;Supervisor: "Test checkout flow, validate all APIs"

    Note over Supervisor: Splits work into two parallel tasks

    par Explorer drives the browser
        Supervisor-&gt;&gt;Explorer: "Navigate to /cart, click Checkout, fill payment form, submit"
        Explorer-&gt;&gt;Browser: Go to /cart
        Browser--&gt;&gt;Explorer: Page loaded
        Explorer-&gt;&gt;Browser: Click "Checkout"
        Browser--&gt;&gt;Explorer: Navigation to /checkout
        Explorer-&gt;&gt;Browser: Fill payment form + Submit
        Browser--&gt;&gt;Explorer: Order confirmation page
        Explorer--&gt;&gt;Supervisor: "✅ Checkout UI completed. Screenshot attached."
    and Validator inspects network
        Supervisor-&gt;&gt;Validator: "Watch all network calls, flag any non-2xx response"
        Browser-&gt;&gt;Backend: GET /api/cart
        Backend--&gt;&gt;Browser: 200 OK
        Validator--&gt;&gt;Validator: ✅ /api/cart → 200
        Browser-&gt;&gt;Backend: POST /api/checkout
        Backend--&gt;&gt;Browser: 422 Unprocessable Entity
        Validator--&gt;&gt;Validator: ⚠️ /api/checkout → 422
        Browser-&gt;&gt;Backend: POST /api/orders
        Backend--&gt;&gt;Browser: 201 Created
        Validator--&gt;&gt;Validator: ✅ /api/orders → 201
        Validator--&gt;&gt;Supervisor: "⚠️ 1 API error: POST /checkout returned 422"
    end

    Supervisor-&gt;&gt;You: "📊 UI flow completed successfully, but POST /api/checkout returned 422 — possible validation error in payment payload. Review screenshot + network log."
</code></pre>

<h3 id="concrete-example-running-a-multi-agent-test">Concrete Example: Running a Multi-Agent Test</h3>

<p>You don’t need a custom framework to start. Here’s a practical three-step workflow using tools you already have:</p>

<p><strong>Step 1 — Write a Playwright script that records everything:</strong></p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Playwright</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">System.Text.Json</span><span class="p">;</span>

<span class="c1">// Explorer Agent: navigate the checkout flow, capture every detail</span>
<span class="kt">var</span> <span class="n">playwright</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Playwright</span><span class="p">.</span><span class="nf">CreateAsync</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">browser</span> <span class="p">=</span> <span class="k">await</span> <span class="n">playwright</span><span class="p">.</span><span class="n">Chromium</span><span class="p">.</span><span class="nf">LaunchAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Headless</span> <span class="p">=</span> <span class="k">false</span> <span class="p">});</span>
<span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="k">await</span> <span class="n">browser</span><span class="p">.</span><span class="nf">NewContextAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span>
<span class="p">{</span>
    <span class="c1">// Record every network request and response</span>
    <span class="n">RecordHarPath</span> <span class="p">=</span> <span class="s">"checkout-trace.har"</span><span class="p">,</span>
    <span class="n">RecordHarMode</span> <span class="p">=</span> <span class="n">HarMode</span><span class="p">.</span><span class="n">Full</span>
<span class="p">});</span>

<span class="kt">var</span> <span class="n">page</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">NewPageAsync</span><span class="p">();</span>

<span class="c1">// Collect all API responses for the Validator to inspect</span>
<span class="kt">var</span> <span class="n">apiResponses</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;();</span>
<span class="n">page</span><span class="p">.</span><span class="n">Response</span> <span class="p">+=</span> <span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="n">response</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">apiResponses</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span>
    <span class="p">{</span>
        <span class="n">url</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">Url</span><span class="p">,</span>
        <span class="n">status</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">Status</span><span class="p">,</span>
        <span class="n">method</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">Request</span><span class="p">.</span><span class="n">Method</span><span class="p">,</span>
        <span class="n">timestamp</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">UtcNow</span>
    <span class="p">});</span>
<span class="p">};</span>

<span class="c1">// Explorer: run the flow</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">GotoAsync</span><span class="p">(</span><span class="s">"https://your-app.com/cart"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">ClickAsync</span><span class="p">(</span><span class="s">"button#checkout"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"input#card-number"</span><span class="p">,</span> <span class="s">"4111111111111111"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">FillAsync</span><span class="p">(</span><span class="s">"input#expiry"</span><span class="p">,</span> <span class="s">"12/28"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">ClickAsync</span><span class="p">(</span><span class="s">"button#place-order"</span><span class="p">);</span>

<span class="c1">// Take a final screenshot for visual validation</span>
<span class="k">await</span> <span class="n">page</span><span class="p">.</span><span class="nf">ScreenshotAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Path</span> <span class="p">=</span> <span class="s">"checkout-final.png"</span><span class="p">,</span> <span class="n">FullPage</span> <span class="p">=</span> <span class="k">true</span> <span class="p">});</span>

<span class="c1">// Save the API trace for the Validator agent</span>
<span class="n">File</span><span class="p">.</span><span class="nf">WriteAllText</span><span class="p">(</span><span class="s">"api-responses.json"</span><span class="p">,</span>
    <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">apiResponses</span><span class="p">,</span> <span class="k">new</span> <span class="n">JsonSerializerOptions</span> <span class="p">{</span> <span class="n">WriteIndented</span> <span class="p">=</span> <span class="k">true</span> <span class="p">}));</span>

<span class="k">await</span> <span class="n">browser</span><span class="p">.</span><span class="nf">CloseAsync</span><span class="p">();</span>
</code></pre></div></div>

<p><strong>Step 2 — The Validator agent checks the collected data:</strong></p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Define the response record that matches what the Explorer captured</span>
<span class="k">public</span> <span class="n">record</span> <span class="nf">ApiResponse</span><span class="p">(</span><span class="kt">string</span> <span class="n">Url</span><span class="p">,</span> <span class="kt">int</span> <span class="n">Status</span><span class="p">,</span> <span class="kt">string</span> <span class="n">Method</span><span class="p">,</span> <span class="n">DateTime</span> <span class="n">Timestamp</span><span class="p">);</span>

<span class="c1">// Validator Agent: read the Explorer's output and verify constraints</span>
<span class="kt">var</span> <span class="n">responses</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">List</span><span class="p">&lt;</span><span class="n">ApiResponse</span><span class="p">&gt;&gt;(</span>
    <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllText</span><span class="p">(</span><span class="s">"api-responses.json"</span><span class="p">));</span>

<span class="kt">var</span> <span class="n">errors</span> <span class="p">=</span> <span class="n">responses</span>
    <span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="n">r</span> <span class="p">=&gt;</span> <span class="n">r</span><span class="p">.</span><span class="n">Status</span> <span class="p">&lt;</span> <span class="m">200</span> <span class="p">||</span> <span class="n">r</span><span class="p">.</span><span class="n">Status</span> <span class="p">&gt;=</span> <span class="m">300</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>

<span class="k">if</span> <span class="p">(</span><span class="n">errors</span><span class="p">.</span><span class="nf">Any</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"⚠️ </span><span class="p">{</span><span class="n">errors</span><span class="p">.</span><span class="n">Count</span><span class="p">}</span><span class="s"> API error(s) detected:"</span><span class="p">);</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">err</span> <span class="k">in</span> <span class="n">errors</span><span class="p">)</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"  </span><span class="p">{</span><span class="n">err</span><span class="p">.</span><span class="n">Method</span><span class="p">}</span><span class="s"> </span><span class="p">{</span><span class="n">err</span><span class="p">.</span><span class="n">Url</span><span class="p">}</span><span class="s"> → </span><span class="p">{</span><span class="n">err</span><span class="p">.</span><span class="n">Status</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"✅ All API calls returned 2xx"</span><span class="p">);</span>
<span class="p">}</span>

<span class="c1">// Validate critical endpoints were called</span>
<span class="kt">var</span> <span class="n">requiredEndpoints</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span> <span class="p">{</span> <span class="s">"/api/cart"</span><span class="p">,</span> <span class="s">"/api/checkout"</span><span class="p">,</span> <span class="s">"/api/orders"</span> <span class="p">};</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">endpoint</span> <span class="k">in</span> <span class="n">requiredEndpoints</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">found</span> <span class="p">=</span> <span class="n">responses</span><span class="p">.</span><span class="nf">Any</span><span class="p">(</span><span class="n">r</span> <span class="p">=&gt;</span> <span class="n">r</span><span class="p">.</span><span class="n">Url</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="n">endpoint</span><span class="p">));</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">found</span>
        <span class="p">?</span> <span class="s">$"✅ Required endpoint </span><span class="p">{</span><span class="n">endpoint</span><span class="p">}</span><span class="s"> was called"</span>
        <span class="p">:</span> <span class="s">$"❌ Missing required endpoint: </span><span class="p">{</span><span class="n">endpoint</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Step 3 — The Supervisor agent (you, or an LLM) reviews both outputs:</strong></p>

<ul>
  <li>Explorer report: screenshot shows the order confirmation page — <strong>UI passed</strong> ✅</li>
  <li>Validator report: <code class="language-plaintext highlighter-rouge">/api/checkout</code> returned 422 — <strong>API validation error</strong> ⚠️</li>
  <li>Verdict: the frontend rendered a success page, but the backend rejected the payment payload. <strong>This bug would be invisible in a UI-only test.</strong></li>
</ul>

<h3 id="why-multi-agent-catches-what-single-agent-misses">Why Multi-Agent Catches What Single-Agent Misses</h3>

<table>
  <thead>
    <tr>
      <th>Testing approach</th>
      <th>What it sees</th>
      <th>What it misses</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Manual click-through</strong></td>
      <td>“The confirmation page loaded”</td>
      <td>API 422 errors, slow backend responses, missing audit trail</td>
    </tr>
    <tr>
      <td><strong>Single-agent UI test</strong></td>
      <td>“All buttons worked, no exceptions”</td>
      <td>Network errors that the UI silently ignored</td>
    </tr>
    <tr>
      <td><strong>Multi-agent (Explorer + Validator)</strong></td>
      <td><strong>Both</strong> — UI state AND network state in the same run</td>
      <td>Almost nothing; any discrepancy between frontend and backend is flagged</td>
    </tr>
  </tbody>
</table>

<h2 id="step-6-when-to-use-each-approach">Step 6: When to Use Each Approach</h2>

<p>After setting up all three layers (Raw Playwright, Playwright MCP, Multi-Agent), here’s how to choose:</p>

<pre><code class="language-mermaid">flowchart TD
    START["🤔 I need to test a web application"] --&gt; Q1{"Is this an exploratory&lt;br/&gt;or one-off task?"}
    Q1 --&gt;|Yes| Q1a{"Do I need to validate&lt;br/&gt;API calls AND UI in the&lt;br/&gt;same session?"}
    Q1a --&gt;|Yes| MULTI["🧠 Multi-Agent&lt;br/&gt;(Explorer + Validator&lt;br/&gt;in parallel)"]
    Q1a --&gt;|No| MCP["🤖 Playwright MCP&lt;br/&gt;+ AI agent&lt;br/&gt;natural language control"]
    Q1 --&gt;|No| Q2{"Is this a repeatable&lt;br/&gt;CI/CD test suite?"}
    Q2 --&gt;|Yes| Q2a{"Do I need network-level&lt;br/&gt;assertions (API status,&lt;br/&gt;payload validation)?"}
    Q2a --&gt;|Yes| RAW_HAR["✍️ Raw Playwright + HAR recording&lt;br/&gt;+ separate Validator script"]
    Q2a --&gt;|No| RAW["✍️ Raw Playwright&lt;br/&gt;with Web-First Assertions"]
    Q2 --&gt;|No| Q3{"Am I prototyping a new&lt;br/&gt;feature and want quick&lt;br/&gt;feedback?"}
    Q3 --&gt;|Yes| MCP
    Q3 --&gt;|No| MULTI
</code></pre>

<ul>
  <li><strong>Raw Playwright + Web-First Assertions</strong> → best for CI/CD regression suites where you need fast, deterministic results with no AI variability.</li>
  <li><strong>Playwright MCP + AI agent</strong> → best for exploratory testing, accessibility audits, one-off validations, and prototyping.</li>
  <li><strong>Multi-Agent (Explorer + Validator)</strong> → best for end-to-end flows where you cannot afford to miss a single API error or frontend-backend mismatch.</li>
</ul>

<h2 id="where-existing-posts-on-this-blog-fit">Where Existing Posts on This Blog Fit</h2>

<p>This post is the 2026 Playwright refresh that connects to four earlier articles on techtalkwith-veeresh:</p>

<table>
  <thead>
    <tr>
      <th>Earlier post</th>
      <th>What it covered</th>
      <th>What changed by 2026</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/mastering-e2e-testing-csharp-playwright/">Mastering E2E Testing with C# Playwright (Jul 2024)</a></td>
      <td>Playwright setup, cross-browser, API calls, SQL Server, tracing</td>
      <td>Web-First Assertions replace manual <code class="language-plaintext highlighter-rouge">Assert.AreEqual</code>; MCP replaces manual script-writing for exploratory work</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/mastering-async-operations-csharp-playwright/">Mastering Async Ops in C# Playwright (Aug 2024)</a></td>
      <td><code class="language-plaintext highlighter-rouge">WaitForResponseAsync</code>, manual deserialization, explicit waits</td>
      <td>Web-First Assertions handle wait-and-assert in one call; multi-agent Validator watches all responses automatically</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/frameworks/mastering-playwright-dotnet/">Playwright .NET Framework Guide (Sep 2024)</a></td>
      <td>NUnit + DI + Page Objects + Allure reporting</td>
      <td>Page Objects become optional when MCP agents resolve interactable elements dynamically; add <code class="language-plaintext highlighter-rouge">@playwright/mcp</code> as a parallel testing mode</td>
    </tr>
    <tr>
      <td><a href="/techtalkwith-veeresh/automation/tools/playwright-vs-selenium-2026/">Playwright vs Selenium in 2026 (Jun 2026)</a></td>
      <td>Speed, reliability, multi-browser comparison</td>
      <td>Playwright now has MCP + multi-agent orchestration — capabilities Selenium’s ecosystem is still building toward</td>
    </tr>
  </tbody>
</table>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://playwright.dev/docs/intro">Playwright Documentation</a> — official guides, API references, and best practices for all languages</li>
  <li><a href="https://playwright.dev/dotnet/docs/intro">Playwright for .NET</a> — C#-specific API reference used in this post’s code examples</li>
  <li><a href="https://github.com/microsoft/playwright-mcp">microsoft/playwright-mcp on GitHub</a> — the official Playwright MCP server, installable via <code class="language-plaintext highlighter-rouge">npx @playwright/mcp</code></li>
  <li><a href="https://modelcontextprotocol.io/">Model Context Protocol Specification</a> — the open protocol that enables AI agents to control browsers and other tools</li>
</ol>

<h2 id="what-to-do-next">What to Do Next</h2>

<ol>
  <li><strong>Run Step 1–2 right now.</strong> Install Playwright and write the Hello World test. It takes under 3 minutes, and the auto-wait behavior will immediately click — you’ll never want to write <code class="language-plaintext highlighter-rouge">Thread.Sleep</code> again.</li>
  <li><strong>Try Playwright MCP.</strong> If you have Claude Desktop, add the <code class="language-plaintext highlighter-rouge">@playwright/mcp</code> config and ask it to navigate to any site. Compare the experience to the <a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium MCP setup</a> — you’ll notice the speed difference immediately.</li>
  <li><strong>Experiment with multi-agent.</strong> Take any existing Playwright test, add HAR recording, and write a 15-line Validator that checks for non-2xx responses. You’ll probably find a bug your UI test was silently ignoring.</li>
  <li><strong>For CI/CD pipelines:</strong> stick with raw Playwright + Web-First Assertions. MCP and multi-agent add AI variability — fine for exploration, not ideal for deterministic pass/fail gates.</li>
  <li><strong>Subscribe to this blog’s <a href="/feed.xml">feed.xml</a></strong> — next up: a deep-dive on Playwright’s AI codegen (<code class="language-plaintext highlighter-rouge">npx playwright codegen --ai</code>) and how to generate an entire test suite from a requirements document.</li>
</ol>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy: From Copilot to Multi-Agent Orchestration (Jun 2026)</a> — the overarching thesis on how multi-agent systems are reshaping QA, from test generation to self-healing suites. · <a href="/techtalkwith-veeresh/automation/tools/playwright-ai-codegen-deep-dive/">Playwright AI Codegen in 2026 (Jul 2026)</a> — the deep-dive teased above: generating test suites from natural language.</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="tools" /><category term="playwright" /><category term="mcp" /><category term="multi-agent" /><category term="ai-testing" /><category term="beginners" /><category term="csharp" /><category term="java" /><category term="typescript" /><category term="javascript" /><category term="python" /><category term="dotnet" /><summary type="html"><![CDATA[Playwright + MCP + multiple AI agents on one test run. The beginner guide — because one agent driving the browser felt a bit lonely.]]></summary></entry><entry><title type="html">XPath ↔ CSS Translation Appendix for SDETs</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix/" rel="alternate" type="text/html" title="XPath ↔ CSS Translation Appendix for SDETs" /><published>2026-07-14T00:00:00+00:00</published><updated>2026-07-14T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix/"><![CDATA[<p>A pocket reference that pairs the XPath you’ve already written with the CSS (or Playwright engine selector) that fits the modern stack. Pre-requisite reading: the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">XPath Cheatsheet §11 Advanced and complex patterns</a>. Every row below cross-references an existing row in §§3–11 of the cheatsheet or §§1–12 of the <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">article</a>.</p>

<blockquote>
  <p><strong>Why this card exists:</strong> XPath 1.0 is the lingua franca of legacy Selenium suites. CSS 2/3 + Level 4 + Playwright ARIA selectors cover the same ground across more runners, but the patterns don’t map 1:1. This card lets you audit existing XPath and propose a CSS-or-engine equivalent for each — without losing stability intent.</p>
</blockquote>

<h2 id="quick-reference-tables">Quick-reference tables</h2>

<p>Five categories cover ~90% of the XPath you’ll find in a real suite. Use the cross-references in the <strong>§ column</strong> to jump to the matching cheatsheet row.</p>

<h3 id="a-text-match">A. Text match</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A1</td>
      <td><code class="language-plaintext highlighter-rouge">//*[normalize-space()='Welcome']</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByText('Welcome')</code></td>
      <td>CSS has no exact-visible-text pseudo</td>
      <td>article §12.5, §12.9</td>
    </tr>
    <tr>
      <td>A2</td>
      <td><code class="language-plaintext highlighter-rouge">//button[text()='Sign in']</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByRole('button', { name: 'Sign in' })</code></td>
      <td>Engine ARIA selector is the cleanest path</td>
      <td>article §12.9</td>
    </tr>
    <tr>
      <td>A3</td>
      <td><code class="language-plaintext highlighter-rouge">//*[contains(., 'Pay')]</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">page.locator(':has-text("Pay")')</code></td>
      <td>Substring text across all descendants</td>
      <td>cheatsheet §5</td>
    </tr>
    <tr>
      <td>A4</td>
      <td><code class="language-plaintext highlighter-rouge">//h2[starts-with(., 'Order')]</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByText('Order', { exact: false })</code></td>
      <td>Prefix match</td>
      <td>cheatsheet §5</td>
    </tr>
    <tr>
      <td>A5</td>
      <td><code class="language-plaintext highlighter-rouge">//h1[normalize-space()='Welcome']</code></td>
      <td><code class="language-plaintext highlighter-rouge">h1</code> (no text match in CSS)</td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('h1').getByText('Welcome')</code></td>
      <td>Combine element tag + ARIA</td>
      <td>article §12.7</td>
    </tr>
  </tbody>
</table>

<h3 id="b-structural-nth">B. Structural nth</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>B1</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[1]</code></td>
      <td><code class="language-plaintext highlighter-rouge">ul li:first-child</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('ul li').first()</code></td>
      <td><code class="language-plaintext highlighter-rouge">:first-child</code> requires true parent</td>
      <td>cheatsheet §11.5</td>
    </tr>
    <tr>
      <td>B2</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[last()]</code></td>
      <td><code class="language-plaintext highlighter-rouge">ul li:last-child</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('ul li').last()</code></td>
      <td>Last-child is selector-engine supported</td>
      <td>cheatsheet §11.5</td>
    </tr>
    <tr>
      <td>B3</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[position() mod 2 = 1]</code></td>
      <td><code class="language-plaintext highlighter-rouge">ul li:nth-child(2n+1)</code> (odd) / <code class="language-plaintext highlighter-rouge">2n</code> (even)</td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">position()</code> is 1-indexed</td>
      <td>cheatsheet §11.1, §11.5</td>
    </tr>
    <tr>
      <td>B4</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[count(preceding-sibling::li) = 2]</code></td>
      <td><code class="language-plaintext highlighter-rouge">ul li:nth-child(3)</code></td>
      <td>—</td>
      <td>“3rd item” — <code class="language-plaintext highlighter-rouge">:nth-child(3)</code> matches intent</td>
      <td>cheatsheet §11.1</td>
    </tr>
    <tr>
      <td>B5</td>
      <td><code class="language-plaintext highlighter-rouge">//li[position() &gt; 5 and position() &lt;= 10]</code></td>
      <td><code class="language-plaintext highlighter-rouge">ul li:nth-child(n+6):nth-child(-n+10)</code></td>
      <td>—</td>
      <td>Range via two <code class="language-plaintext highlighter-rouge">:nth-child()</code></td>
      <td>cheatsheet §11.1</td>
    </tr>
    <tr>
      <td>B6</td>
      <td><code class="language-plaintext highlighter-rouge">//td[nth-of-type=4]</code></td>
      <td><code class="language-plaintext highlighter-rouge">td:nth-of-type(4)</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('td').nth(3)</code></td>
      <td>Engine is 0-indexed; CSS uses an+b</td>
      <td>cheatsheet §11.5</td>
    </tr>
    <tr>
      <td>B7</td>
      <td><code class="language-plaintext highlighter-rouge">//tr[count(td) &gt; 5]</code></td>
      <td><code class="language-plaintext highlighter-rouge">tr:where(:has(*:nth-child(6)))</code> (heuristic)</td>
      <td>—</td>
      <td>“Has at least 6 children” — better: measure via Playwright <code class="language-plaintext highlighter-rouge">.count()</code>, don’t express in selector</td>
      <td>cheatsheet §11.1</td>
    </tr>
    <tr>
      <td>B8</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[only-child]</code> equivalent: first list item with no siblings</td>
      <td><code class="language-plaintext highlighter-rouge">li:only-child</code></td>
      <td>—</td>
      <td>Type-aware</td>
      <td>cheatsheet §11.5</td>
    </tr>
  </tbody>
</table>

<h3 id="c-ancestor--reverse-navigation">C. Ancestor / reverse navigation</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>C1</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@name='card']/ancestor::form</code></td>
      <td><code class="language-plaintext highlighter-rouge">form:has(input[name='card'])</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('form').filter({ has: page.locator('input[name="card"]') })</code></td>
      <td><code class="language-plaintext highlighter-rouge">:has()</code> is the only reverse parent selector; chained <code class="language-plaintext highlighter-rouge">.locator()</code> scopes to descendants, so <code class="language-plaintext highlighter-rouge">ancestor::</code> cannot be chained</td>
      <td>cheatsheet §11.4, §11.9</td>
    </tr>
    <tr>
      <td>C2</td>
      <td><code class="language-plaintext highlighter-rouge">//section[.//h2[normalize-space()='Billing']]</code></td>
      <td><code class="language-plaintext highlighter-rouge">section:has(h2)</code> (text-matching not possible in pure CSS L4)</td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('section').filter({ has: page.getByRole('heading', { name: 'Billing' }) })</code></td>
      <td>Filter <code class="language-plaintext highlighter-rouge">has</code> + ARIA = cleanest reverse; pure CSS would need a stable hook such as <code class="language-plaintext highlighter-rouge">section:has(h2[data-billing])</code> if the team agrees to mark up the heading</td>
      <td>article §12.4, cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>C3</td>
      <td><code class="language-plaintext highlighter-rouge">//div/..</code> (parent of a div)</td>
      <td>parent is implicit (<code class="language-plaintext highlighter-rouge">div</code>’s parent)</td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('div').locator('..')</code></td>
      <td>Engine exposes parent via XPath <code class="language-plaintext highlighter-rouge">..</code></td>
      <td>cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>C4</td>
      <td><code class="language-plaintext highlighter-rouge">//form[.//button[@disabled and @type='submit']]</code></td>
      <td><code class="language-plaintext highlighter-rouge">form:has(button[type='submit'][disabled])</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('form').filter({ has: page.locator('button[type="submit"][disabled]') })</code></td>
      <td>Filter pattern &gt; direct selector</td>
      <td>cheatsheet §11.9</td>
    </tr>
    <tr>
      <td>C5</td>
      <td><code class="language-plaintext highlighter-rouge">//tr/preceding-sibling::tr</code></td>
      <td><code class="language-plaintext highlighter-rouge">tr ~ tr</code> (general sibling before)</td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">~</code> matches from anywhere; preceding via CSS is “earlier in document order” trickier</td>
      <td>cheatsheet §11.7</td>
    </tr>
  </tbody>
</table>

<h3 id="d-attribute-complex-predicates">D. Attribute complex predicates</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>D1</td>
      <td><code class="language-plaintext highlighter-rouge">//a[contains(@href, '/orders/')]</code></td>
      <td><code class="language-plaintext highlighter-rouge">a[href*='/orders/']</code></td>
      <td>—</td>
      <td>Substring match</td>
      <td>cheatsheet §4</td>
    </tr>
    <tr>
      <td>D2</td>
      <td><code class="language-plaintext highlighter-rouge">//div[starts-with(@class, 'order-')]</code></td>
      <td><code class="language-plaintext highlighter-rouge">div[class^='order-']</code></td>
      <td>—</td>
      <td>Prefix match</td>
      <td>cheatsheet §4</td>
    </tr>
    <tr>
      <td>D3</td>
      <td><code class="language-plaintext highlighter-rouge">//a[ends-with — XPath 1.0 has no $]</code></td>
      <td><code class="language-plaintext highlighter-rouge">a[href$='.pdf']</code></td>
      <td>—</td>
      <td>XPath 1.0 has no ends-with; CSS <code class="language-plaintext highlighter-rouge">^=</code> and <code class="language-plaintext highlighter-rouge">$=</code> are clean alternatives</td>
      <td>cheatsheet §4</td>
    </tr>
    <tr>
      <td>D4</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@type='checkbox' and @name='agree']</code></td>
      <td><code class="language-plaintext highlighter-rouge">input[type='checkbox'][name='agree']</code></td>
      <td>—</td>
      <td>Compound exact attrs</td>
      <td>cheatsheet §5</td>
    </tr>
    <tr>
      <td>D5</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@type='submit' or @type='button']</code></td>
      <td><code class="language-plaintext highlighter-rouge">input:is([type='submit'], [type='button'])</code></td>
      <td>—</td>
      <td>OR via <code class="language-plaintext highlighter-rouge">:is()</code></td>
      <td>cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>D6</td>
      <td><code class="language-plaintext highlighter-rouge">//button[not(@disabled)]</code></td>
      <td><code class="language-plaintext highlighter-rouge">button:not([disabled])</code></td>
      <td>—</td>
      <td>Negation</td>
      <td>cheatsheet §5</td>
    </tr>
    <tr>
      <td>D7</td>
      <td><code class="language-plaintext highlighter-rouge">//a[translate(@href, 'A..Z', 'a..z') = '/help']</code></td>
      <td><code class="language-plaintext highlighter-rouge">a[href='/help' i]</code></td>
      <td>—</td>
      <td>Modern CSS case-insensitive flag collapses <code class="language-plaintext highlighter-rouge">translate()</code></td>
      <td>article §12.7</td>
    </tr>
    <tr>
      <td>D8</td>
      <td><code class="language-plaintext highlighter-rouge">//a[contains(@href, 'github')]</code> (case-insensitive intent)</td>
      <td><code class="language-plaintext highlighter-rouge">a[href*='github' i]</code></td>
      <td>—</td>
      <td>Case-insensitive substring</td>
      <td>article §12.7</td>
    </tr>
    <tr>
      <td>D9</td>
      <td><code class="language-plaintext highlighter-rouge">//button[starts-with(@id, 'submit-') and not(starts-with(@id, 'submit-draft-'))]</code></td>
      <td><code class="language-plaintext highlighter-rouge">button[id^='submit-']:not([id^='submit-draft-'])</code></td>
      <td>—</td>
      <td>Negative prefix via <code class="language-plaintext highlighter-rouge">:not()</code></td>
      <td>cheatsheet §11.1</td>
    </tr>
  </tbody>
</table>

<h3 id="e-boolean-and-combinator-choices">E. Boolean and combinator choices</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>E1</td>
      <td><code class="language-plaintext highlighter-rouge">//button[@type='submit' and not(@disabled)]</code></td>
      <td><code class="language-plaintext highlighter-rouge">button[type='submit']:not([disabled])</code></td>
      <td>—</td>
      <td>Multi-condition</td>
      <td>cheatsheet §5</td>
    </tr>
    <tr>
      <td>E2</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@type='text' or @type='email']</code></td>
      <td><code class="language-plaintext highlighter-rouge">input:is([type='text'], [type='email'])</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">:is()</code> for OR</td>
      <td>cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>E3</td>
      <td><code class="language-plaintext highlighter-rouge">//h1.title \| //h2.title \| //h3.title</code></td>
      <td><code class="language-plaintext highlighter-rouge">.title:is(h1, h2, h3)</code></td>
      <td>—</td>
      <td>Comma-OR with kept specificity</td>
      <td>cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>E4</td>
      <td>E3 with zero specificity</td>
      <td><code class="language-plaintext highlighter-rouge">.title:where(h1, h2, h3)</code></td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">:where()</code> zero specificity</td>
      <td>cheatsheet §11.4</td>
    </tr>
    <tr>
      <td>E5</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@name='card']/following-sibling::input</code></td>
      <td><code class="language-plaintext highlighter-rouge">input[name='card'] ~ input</code> (general sibling, any tag)</td>
      <td>—</td>
      <td><code class="language-plaintext highlighter-rouge">~</code> matches any sibling after</td>
      <td>cheatsheet §11.7</td>
    </tr>
    <tr>
      <td>E6</td>
      <td><code class="language-plaintext highlighter-rouge">//label[normalize-space()='Email']/following-sibling::input</code></td>
      <td><code class="language-plaintext highlighter-rouge">label:has-text('Email') + input</code> (engine composite)</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByLabel('Email')</code></td>
      <td>Direct adjacency</td>
      <td>cheatsheet §11.7, article §12.9</td>
    </tr>
    <tr>
      <td>E7</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@invalid]/..//.error-icon</code></td>
      <td><code class="language-plaintext highlighter-rouge">input:invalid ~ .error-icon:first-of-type</code></td>
      <td>CSS string <code class="language-plaintext highlighter-rouge">input:invalid ~ .error-icon</code> works directly in Playwright</td>
      <td>State + sibling combiner</td>
      <td>cheatsheet §11.7</td>
    </tr>
  </tbody>
</table>

<h3 id="f-state-driven-predicates">F. State-driven predicates</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Notes</th>
      <th>§</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>F1</td>
      <td><code class="language-plaintext highlighter-rouge">//button[not(@disabled)]</code></td>
      <td><code class="language-plaintext highlighter-rouge">button:not([disabled])</code> or <code class="language-plaintext highlighter-rouge">button:enabled</code></td>
      <td>Static + state pseudo</td>
      <td>cheatsheet §11.6</td>
    </tr>
    <tr>
      <td>F2</td>
      <td><code class="language-plaintext highlighter-rouge">//input[not(@readonly)]</code></td>
      <td><code class="language-plaintext highlighter-rouge">input:not([readonly])</code></td>
      <td>Same</td>
      <td>cheatsheet §11.6</td>
    </tr>
    <tr>
      <td>F3</td>
      <td><code class="language-plaintext highlighter-rouge">//div[@aria-hidden='false']</code></td>
      <td><code class="language-plaintext highlighter-rouge">div[aria-hidden='false']</code></td>
      <td>Direct ARIA attribute mirror</td>
      <td>cheatsheet §11.1, §11.9</td>
    </tr>
    <tr>
      <td>F4</td>
      <td><code class="language-plaintext highlighter-rouge">//div[@role='treeitem' and @aria-expanded='true']</code></td>
      <td><code class="language-plaintext highlighter-rouge">div[role='treeitem'][aria-expanded='true']</code></td>
      <td>ARIA chain via CSS — verbose vs Playwright ARIA</td>
      <td>article §12.3</td>
    </tr>
    <tr>
      <td>F5</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@type='radio' and not(@disabled)]</code></td>
      <td><code class="language-plaintext highlighter-rouge">input[type='radio']:not(:disabled)</code></td>
      <td>Combine attribute + state pseudo</td>
      <td>article §12.7</td>
    </tr>
    <tr>
      <td>F6</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@placeholder and normalize-space(@value)='']</code></td>
      <td><code class="language-plaintext highlighter-rouge">input:placeholder-shown</code></td>
      <td>CSS pseudo-stateful: matches inputs showing placeholder</td>
      <td>article §12.7</td>
    </tr>
    <tr>
      <td>F7</td>
      <td><code class="language-plaintext highlighter-rouge">//form[.//input:focus]</code></td>
      <td><code class="language-plaintext highlighter-rouge">form:focus-within</code></td>
      <td>Pseudo for “form containing focus”</td>
      <td>article §12.7</td>
    </tr>
  </tbody>
</table>

<h2 id="the-no-clean-css-translation-exists-catalog">The “no clean CSS translation exists” catalog</h2>

<p>These XPath patterns have <strong>no pure CSS equivalent</strong> — they require engine selectors (Playwright ARIA / text), framework boundary APIs, or stay XPath forever. Don’t fight them; recognize and route around:</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>XPath pattern</th>
      <th>Why CSS can’t match it</th>
      <th>What to use instead</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>X1</td>
      <td><code class="language-plaintext highlighter-rouge">//*[local-name()='svg']//*[local-name()='path']</code></td>
      <td>SVG needs XML namespace-aware matching</td>
      <td>Playwright CSS <code class="language-plaintext highlighter-rouge">svg path[fill='…']</code> works Chromium 105+; otherwise keep the XPath for cross-browser matrix runs</td>
    </tr>
    <tr>
      <td>X2</td>
      <td><code class="language-plaintext highlighter-rouge">//iframe/...</code> after frame switch</td>
      <td>iframes are document boundaries</td>
      <td>Playwright <code class="language-plaintext highlighter-rouge">page.frameLocator(iframe).locator(sel)</code>; Selenium <code class="language-plaintext highlighter-rouge">driver.switchTo().frame(name)</code></td>
    </tr>
    <tr>
      <td>X3</td>
      <td><code class="language-plaintext highlighter-rouge">//*[any-deeper shadow-root descendant]</code></td>
      <td>Shadow DOM is a separate tree</td>
      <td>Playwright <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> chain; Selenium via <code class="language-plaintext highlighter-rouge">shadowRoot.evaluate(...)</code></td>
    </tr>
    <tr>
      <td>X4</td>
      <td><code class="language-plaintext highlighter-rouge">//*[ancestor::*[position()=1]/@data-state='ready']]</code> deep state-machine XPath</td>
      <td>No ancestor-state predicate in CSS</td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('host').filter({ has: page.locator('[data-state="ready"]') })</code></td>
    </tr>
    <tr>
      <td>X5</td>
      <td><code class="language-plaintext highlighter-rouge">//div[count(preceding-sibling::div[@data-section]) = 2]</code> (computed nth with conditional skip)</td>
      <td><code class="language-plaintext highlighter-rouge">:nth-child()</code> is unconditional</td>
      <td>Keep the XPath; or filter results in code</td>
    </tr>
    <tr>
      <td>X6</td>
      <td><code class="language-plaintext highlighter-rouge">//*[contains(normalize-space(.),'multiple descendants merged')]</code></td>
      <td>CSS has no descendant-text concat</td>
      <td>Playwright <code class="language-plaintext highlighter-rouge">text=/regex/</code> with auto-retry (<code class="language-plaintext highlighter-rouge">text-concat</code> is XPath-specific)</td>
    </tr>
    <tr>
      <td>X7</td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[td[1]/span]</code> (XML-style element child without intermediate match)</td>
      <td>CSS only matches by tag, not by parent-axis path</td>
      <td>Playwright locator chain: <code class="language-plaintext highlighter-rouge">page.locator('table tr').filter({ has: page.locator('td:nth-child(1) span') })</code></td>
    </tr>
  </tbody>
</table>

<h2 id="worked-through-usage-examples">Worked-through usage examples</h2>

<blockquote>
  <p><strong>Before you start — what XPath and CSS actually mean.</strong> Think of the page like a city map. <strong>XPath</strong> is like giving driving directions based on relative landmarks: <em>“Go to the third street from the corner, then find the first door.”</em> <strong>CSS</strong> is like addressing by traits: <em>“Find the house with a red door, then find the mailbox.”</em> They both end up at the same element, but they calculate the route entirely differently. XPath can move <strong>forward</strong>, <strong>backward</strong>, and <strong>sideways</strong> through the DOM (Document Object Model — the browser’s in-memory tree of every element on the page); CSS can only move <strong>forward</strong> but runs ~25% faster. Tables A–F below show the same target written in both languages.</p>
</blockquote>

<h3 id="anatomy-of-a-css-selector">Anatomy of a CSS selector</h3>

<p>When a test runner sees something like <code class="language-plaintext highlighter-rouge">button[type='submit']:not([disabled])</code>, it does <strong>not</strong> read the whole string at once — it processes left-to-right, peeling off one filter at a time. Picture it like a bouncer checking a guest list:</p>

<pre><code class="language-mermaid">flowchart LR
    classDef node fill:#1e293b,stroke:#38bdf8,color:#f0f2f7,stroke-width:1px
    classDef match fill:#0ea5c7,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px

    In["button[type='submit']:not([disabled])"]:::node
    S1["1. Match tag&lt;br&gt;find every button on the page"]:::node
    S2["2. Filter by attribute&lt;br&gt;keep only type='submit'"]:::node
    S3["3. Filter by pseudo-class&lt;br&gt;exclude any that are disabled"]:::node
    Out["Matched DOM element(s)"]:::match

    In --&gt; S1 --&gt; S2 --&gt; S3 --&gt; Out
</code></pre>

<p><em>The same left-to-right peeling happens with XPath — <code class="language-plaintext highlighter-rouge">//button[@type='submit' and not(@disabled)]</code> literally says the same thing in another dialect. That’s why this appendix exists: not because one is “better”, but because one runner prefers one and a different runner prefers the other.</em></p>

<p>Tables A–F compare selector strings. The three scenarios below show them wired through a real DOM → Page Object → spec → assertion chain. Every scenario cross-references its source rows so you can audit each selector against the table claim. <strong>All XPath below uses strict XPath 1.0 discipline</strong> (writing XPath <em>without</em> using 2.0+ features like <code class="language-plaintext highlighter-rouge">lower-case()</code> / regex / FLWOR that standard browsers silently ignore) per cheatsheet §4⚠️.</p>

<h3 id="scenario-1--login-form-a2--e1--f1">Scenario 1 — Login form: A2 + E1 + F1</h3>

<p>A textbook text-match + state-driven form, against <a href="https://the-internet.herokuapp.com/login">the-internet.herokuapp.com/login</a>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">id=</span><span class="s">"login"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"username"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"password"</span> <span class="na">type=</span><span class="s">"password"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Sign in<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>Picture that form as a tiny tree — three children hanging off the form, one of them glowing because it’s what we’re hunting for:</p>

<pre><code class="language-mermaid">flowchart TD
    classDef elt fill:#1e293b,stroke:#9ca3b8,color:#f0f2f7,stroke-width:1px
    classDef target fill:#0ea5c7,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px

    Form["form#login&lt;br&gt;(the parent)"]:::elt
    U["input[name='username']&lt;br&gt;✗ not a button"]:::elt
    P["input[name='password']&lt;br&gt;✗ not a button"]:::elt
    B["button[type='submit']&lt;br&gt;(Sign in) ✓ this is our target"]:::target

    Form --&gt; U
    Form --&gt; P
    Form --&gt; B
</code></pre>

<p>Now read the CSS selector <code class="language-plaintext highlighter-rouge">button[type='submit']:not([disabled])</code> like English:</p>

<blockquote>
  <p><strong>Plain English walk-through</strong></p>
  <ul>
    <li><strong><code class="language-plaintext highlighter-rouge">button</code></strong> — <em>“find every <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code> tag in the page.”</em> (Skips the two inputs immediately.)</li>
    <li><strong><code class="language-plaintext highlighter-rouge">[type='submit']</code></strong> — <em>“…but only the ones whose <code class="language-plaintext highlighter-rouge">type</code> attribute is exactly the word <code class="language-plaintext highlighter-rouge">submit</code>.”</em></li>
    <li><strong><code class="language-plaintext highlighter-rouge">:not([disabled])</code></strong> — <em>“…and throw away any that currently have the <code class="language-plaintext highlighter-rouge">disabled</code> attribute.”</em> (<code class="language-plaintext highlighter-rouge">disabled</code> is a state-driven property — it changes at runtime; a button can be enabled when the form loads and disabled when the user types nothing.)</li>
  </ul>

  <p>Result: <em>one</em> element, the enabled Pay/Sign-in button. The CSS row maps to <strong>F1</strong> (state-driven) and <strong>E1</strong> (boolean combo) in the tables above.</p>
</blockquote>

<p>Locator forms (rows cited for rationale):</p>

<table>
  <thead>
    <tr>
      <th>Form</th>
      <th>Snippet</th>
      <th>Source row</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>XPath</strong></td>
      <td><code class="language-plaintext highlighter-rouge">//button[text()='Sign in']</code> · <code class="language-plaintext highlighter-rouge">//button[@type='submit' and not(@disabled)]</code></td>
      <td>A2, E1</td>
    </tr>
    <tr>
      <td><strong>CSS</strong></td>
      <td><code class="language-plaintext highlighter-rouge">button[type='submit']:not([disabled])</code></td>
      <td>F1</td>
    </tr>
    <tr>
      <td><strong>Playwright engine</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.getByRole('button', { name: 'Sign in', exact: true })</code></td>
      <td>E1 (state-aware role match)</td>
    </tr>
  </tbody>
</table>

<p>Page Object (TS strict, constructor-body init per cheatsheet §10.1):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="kd">type</span> <span class="p">{</span> <span class="nx">Page</span><span class="p">,</span> <span class="nx">Locator</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">playwright</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nx">LoginPage</span> <span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">form</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">username</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">password</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">submitBtn</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">page</span> <span class="o">=</span> <span class="nx">page</span><span class="p">;</span>
    <span class="c1">// Tag + ID beats compound predicates here</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">form</span>     <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">form#login</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">username</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='username']</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">password</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='password']</span><span class="dl">"</span><span class="p">);</span>
    <span class="c1">// E1 + F1: type='submit' AND not(:disabled)</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">submitBtn</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">button[type='submit']:not([disabled])</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">loginAs</span><span class="p">(</span><span class="nx">user</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">pass</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">username</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">password</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="nx">pass</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">submitBtn</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Selenium Java equivalent (for cross-runner parity):</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoginPage</span> <span class="o">{</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">form</span>      <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">"form#login"</span><span class="o">);</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">username</span>  <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">"input[name='username']"</span><span class="o">);</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">password</span>  <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">"input[name='password']"</span><span class="o">);</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">submitBtn</span> <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">"button[type='submit']:not([disabled])"</span><span class="o">);</span>
  <span class="kd">public</span> <span class="nf">LoginPage</span><span class="o">(</span><span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">)</span> <span class="o">{</span> <span class="k">this</span><span class="o">.</span><span class="na">driver</span> <span class="o">=</span> <span class="n">driver</span><span class="o">;</span> <span class="o">}</span>
  <span class="kd">public</span> <span class="nc">LoginPage</span> <span class="nf">loginAs</span><span class="o">(</span><span class="nc">String</span> <span class="n">user</span><span class="o">,</span> <span class="nc">String</span> <span class="n">pass</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">username</span><span class="o">).</span><span class="na">sendKeys</span><span class="o">(</span><span class="n">user</span><span class="o">);</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">password</span><span class="o">).</span><span class="na">sendKeys</span><span class="o">(</span><span class="n">pass</span><span class="o">);</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">submitBtn</span><span class="o">).</span><span class="na">click</span><span class="o">();</span>
    <span class="k">return</span> <span class="k">this</span><span class="o">;</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Spec snippet — asserts on URL and a state, not on in-DOM text:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">LoginPage</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./pages/LoginPage</span><span class="dl">"</span><span class="p">;</span>

<span class="nx">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="dl">"</span><span class="s2">Scenario 1 · login (A2 + D6 + E1 + F1)</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">test</span><span class="p">(</span><span class="dl">"</span><span class="s2">valid creds redirect to /secure</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">lp</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">LoginPage</span><span class="p">(</span><span class="nx">page</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/login</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">lp</span><span class="p">.</span><span class="nx">loginAs</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">SuperSecretPassword!</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">).</span><span class="nx">toHaveURL</span><span class="p">(</span><span class="sr">/</span><span class="se">\/</span><span class="sr">secure$/</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByRole</span><span class="p">(</span><span class="dl">"</span><span class="s2">button</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Logout</span><span class="dl">"</span> <span class="p">})).</span><span class="nx">toBeVisible</span><span class="p">();</span>
  <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Verdict:</strong> ship the Playwright engine form as primary (<code class="language-plaintext highlighter-rouge">getByRole('button', { name: 'Sign in' })</code>) for accessibility-first intent; keep <code class="language-plaintext highlighter-rouge">button[type='submit']:not([disabled])</code> as the CSS fallback for Cypress + Selenium 4. Skip the XPath here — three readings of the same intent aren’t worth the maintenance tax.</p>

<h3 id="scenario-2--dynamic-striped-table-a4--b3--d8--f5">Scenario 2 — Dynamic striped table: A4 + B3 + D8 + F5</h3>

<p>A managing-listings table from the-internet’s <em>Sortable Data Tables</em> page. Find the rows whose status column reads <code class="language-plaintext highlighter-rouge">Open</code> (case-insensitive), restricted to odd table rows:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;table&gt;</span>
  <span class="nt">&lt;thead&gt;&lt;tr&gt;&lt;th&gt;</span>Last Name<span class="nt">&lt;/th&gt;&lt;th&gt;</span>First Name<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Email<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Status<span class="nt">&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;</span>
  <span class="nt">&lt;tbody&gt;</span>
    <span class="nt">&lt;tr&gt;&lt;td&gt;</span>Smith<span class="nt">&lt;/td&gt;&lt;td&gt;</span>John<span class="nt">&lt;/td&gt;&lt;td&gt;</span>jsmith@example.com<span class="nt">&lt;/td&gt;&lt;td</span> <span class="na">class=</span><span class="s">"status status-open"</span><span class="nt">&gt;</span>OPEN<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
    <span class="nt">&lt;tr&gt;&lt;td&gt;</span>Bach<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Frank<span class="nt">&lt;/td&gt;&lt;td&gt;</span>fbach@example.com<span class="nt">&lt;/td&gt;&lt;td</span> <span class="na">class=</span><span class="s">"status status-closed"</span><span class="nt">&gt;</span>Closed<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
    <span class="nt">&lt;tr&gt;&lt;td&gt;</span>Doe<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Jason<span class="nt">&lt;/td&gt;&lt;td&gt;</span>jdoe@example.com<span class="nt">&lt;/td&gt;&lt;td</span> <span class="na">class=</span><span class="s">"status status-open"</span><span class="nt">&gt;</span>Open<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
    <span class="nt">&lt;tr&gt;&lt;td&gt;</span>Conway<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Tim<span class="nt">&lt;/td&gt;&lt;td&gt;</span>tconway@example.com<span class="nt">&lt;/td&gt;&lt;td</span> <span class="na">class=</span><span class="s">"status status-closed"</span><span class="nt">&gt;</span>Closed<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
  <span class="nt">&lt;/tbody&gt;</span>
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<p>Here’s the same table drawn as a tree — odd rows stand out (alternate shading, like real spreadsheets), and the rows whose Status column text is “Open-ish” are marked:</p>

<pre><code class="language-mermaid">flowchart TD
    classDef odd fill:#1e293b,stroke:#38bdf8,color:#f0f2f7,stroke-width:1px
    classDef even fill:#0f1119,stroke:#6b7394,color:#6b7394,stroke-width:1px
    classDef match fill:#0ea5c7,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px
    classDef head fill:#1e293b,stroke:#fbbf24,color:#f0f2f7,stroke-width:1px

    T["table"]:::head
    TH["thead — header row&lt;br&gt;(excluded — not a body row)"]:::head
    R1["tr:nth-child(odd) — Smith&lt;br&gt;OPEN ✓ matches"]:::odd
    R2["tr:nth-child(even) — Bach&lt;br&gt;(skipped, not odd)"]:::even
    R3["tr:nth-child(odd) — Doe&lt;br&gt;Open ✓ matches"]:::odd
    R4["tr:nth-child(even) — Conway&lt;br&gt;(skipped, not odd)"]:::even

    T --&gt; TH
    T --&gt; R1
    T --&gt; R2
    T --&gt; R3
    T --&gt; R4
</code></pre>

<p>Now decode <code class="language-plaintext highlighter-rouge">tr:nth-child(odd) td[class*='open' i]</code> like English:</p>

<blockquote>
  <p><strong>Plain English walk-through</strong></p>
  <ul>
    <li><strong><code class="language-plaintext highlighter-rouge">tr</code></strong> — <em>“only look at table rows.”</em> (<code class="language-plaintext highlighter-rouge">&lt;thead&gt;</code> lives in a different parent, so its <code class="language-plaintext highlighter-rouge">&lt;th&gt;</code> cells are out of scope.)</li>
    <li><strong><code class="language-plaintext highlighter-rouge">:nth-child(odd)</code></strong> (<strong>B3</strong>) — <em>“…and only the rows whose position among their tbody siblings is <strong>1, 3, 5, …</strong>”</em> — Inside <code class="language-plaintext highlighter-rouge">&lt;tbody&gt;</code>, Smith is the 1st child (odd ✓), Bach the 2nd (skip), Doe the 3rd (odd ✓), Conway the 4th (skip). So <code class="language-plaintext highlighter-rouge">:nth-child(odd)</code> keeps <strong>Smith and Doe only</strong> — Conway is <code class="language-plaintext highlighter-rouge">:nth-child(4)</code> and is skipped.</li>
    <li><strong><code class="language-plaintext highlighter-rouge">td</code></strong> <em>(the column we care about)</em> — <em>“…inside those odd rows, examine the cells.”</em></li>
    <li><strong><code class="language-plaintext highlighter-rouge">[class*='open' i]</code></strong> (<strong>D8</strong>) — <em>“…keep cells whose <code class="language-plaintext highlighter-rouge">class</code> attribute <strong>contains</strong> the substring <code class="language-plaintext highlighter-rouge">open</code> (<code class="language-plaintext highlighter-rouge">*=</code> means “contains”), and do it <strong>case-insensitively</strong> (<code class="language-plaintext highlighter-rouge">i</code> means <code class="language-plaintext highlighter-rouge">'open'</code>, <code class="language-plaintext highlighter-rouge">'OPEN'</code>, and <code class="language-plaintext highlighter-rouge">'Open'</code> all match).</em> The fixture encodes state in the class: open rows carry <code class="language-plaintext highlighter-rouge">class="status status-open"</code> and closed rows carry <code class="language-plaintext highlighter-rouge">class="status status-closed"</code>. So <code class="language-plaintext highlighter-rouge">[class*='open' i]</code> literally matches Smith’s and Doe’s cells and skips Bach’s and Conway’s.</li>
  </ul>

  <p>In plain English: <em>“In the body rows whose position is 1 or 3, find the Status cells whose class contains the word <code class="language-plaintext highlighter-rouge">open</code> — regardless of capitalization.”</em> Smith and Doe match; Bach and Conway don’t.</p>
</blockquote>

<p>(Note: the <strong>element-level</strong> alternative is <code class="language-plaintext highlighter-rouge">page.locator('tr').nth(0)</code> / <code class="language-plaintext highlighter-rouge">.nth(2)</code> for the 0-indexed engine equivalent — <code class="language-plaintext highlighter-rouge">nth-child()</code> is 1-indexed <em>and</em> tag-specific, so it does not always match <code class="language-plaintext highlighter-rouge">.nth()</code> intent. Use the explicit index when count restarts mid-row.)</p>
<blockquote>

  <p>The XPath equivalent uses the clunky <code class="language-plaintext highlighter-rouge">translate()</code> function (a workaround from XPath 1.0 that maps every uppercase letter A–Z to its lowercase a–z, then compares) instead of <code class="language-plaintext highlighter-rouge">lower-case()</code> because <code class="language-plaintext highlighter-rouge">lower-case()</code> is XPath 2.0+ and silently returns nothing in browsers. The CSS <code class="language-plaintext highlighter-rouge">[class*='open' i]</code> flag is shorter and faster.</p>
</blockquote>

<p>Locator forms:</p>

<table>
  <thead>
    <tr>
      <th>Form</th>
      <th>Snippet</th>
      <th>Source row</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>XPath</strong></td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[td[translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = 'open']]</code> · <code class="language-plaintext highlighter-rouge">//table//tr[position() mod 2 = 1]</code></td>
      <td>A4 (case-insensitive via <code class="language-plaintext highlighter-rouge">translate()</code> — XPath 1.0 safe), B3</td>
    </tr>
    <tr>
      <td><strong>CSS</strong></td>
      <td><code class="language-plaintext highlighter-rouge">tr:nth-child(odd) td.status</code> · <code class="language-plaintext highlighter-rouge">tr:has(td[class*='open' i])</code></td>
      <td>B3, D8 (<code class="language-plaintext highlighter-rouge">[attr*='open' i]</code> flag)</td>
    </tr>
    <tr>
      <td><strong>Playwright engine</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.getByRole('row').filter({ has: page.locator('td.status:has-text(/open/i)') })</code></td>
      <td>F5 (state + role)</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>⚠️ The XPath version above uses <code class="language-plaintext highlighter-rouge">translate()</code> per article §12.7 / cheatsheet §11.6 — do <strong>not</strong> substitute <code class="language-plaintext highlighter-rouge">lower-case()</code>; WebDriver silently fails on XPath 2.0 functions. Use the CSS <code class="language-plaintext highlighter-rouge">[attr*='open' i]</code> form wherever the runner supports CSS Level 4.</p>
</blockquote>

<p>Page Object (TS):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">OrdersTablePage</span> <span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">rows</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="c1">// B3: odd rows only, used in two spec variants</span>
  <span class="k">readonly</span> <span class="nx">oddRows</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">page</span> <span class="o">=</span> <span class="nx">page</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">rows</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">table tbody tr</span><span class="dl">"</span><span class="p">);</span>
    <span class="c1">// B3 mod 2 = 1 → CSS nth-child(odd); D8 attribute case-insensitive flag</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">oddRows</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">rows</span><span class="p">.</span><span class="nx">filter</span><span class="p">({</span>
      <span class="na">has</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">td:nth-child(4):has-text(/open/i)</span><span class="dl">"</span><span class="p">),</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">oddOpenRows</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">readonly</span> <span class="kr">string</span><span class="p">[]</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">oddRows</span><span class="p">.</span><span class="nx">allInnerTexts</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Spec snippet — verifies behavior across the row alphabetization:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">OrdersTablePage</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./pages/OrdersTablePage</span><span class="dl">"</span><span class="p">;</span>

<span class="nx">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="dl">"</span><span class="s2">Scenario 2 · striped table (A4 + B3 + D8 + F5)</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">test</span><span class="p">(</span><span class="dl">"</span><span class="s2">odd rows show 'Open' status regardless of case/ordering</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">tp</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">OrdersTablePage</span><span class="p">(</span><span class="nx">page</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/tables</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">openRows</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">tp</span><span class="p">.</span><span class="nx">oddOpenRows</span><span class="p">();</span>
    <span class="nx">expect</span><span class="p">(</span><span class="nx">openRows</span><span class="p">.</span><span class="nx">length</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
    <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">text</span> <span class="k">of</span> <span class="nx">openRows</span><span class="p">)</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">text</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">()).</span><span class="nx">toContain</span><span class="p">(</span><span class="dl">"</span><span class="s2">open</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Verdict:</strong> B3’s CSS <code class="language-plaintext highlighter-rouge">tr:nth-child(odd)</code> is faster than <code class="language-plaintext highlighter-rouge">position() mod 2 = 1</code> by ~25% in headless Chromium (per cheatsheet §11.8 row on speed), and D8’s <code class="language-plaintext highlighter-rouge">i</code> attribute flag is shorter than <code class="language-plaintext highlighter-rouge">translate()</code>. Ship CSS + filter-<code class="language-plaintext highlighter-rouge">hasText</code> regex as the primary; keep the XPath version only in your Selenium 3 / older-Cypress fallback file.</p>

<h3 id="scenario-3--reverse-tree-aria-c1--c2--x3-hint">Scenario 3 — Reverse-tree ARIA: C1 + C2 + X3 hint</h3>

<p>A settings panel whose sections anchor on their header text, and one of those settings lives inside a shadow-rooted <code class="language-plaintext highlighter-rouge">payment-form</code> component:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;section&gt;</span>
  <span class="nt">&lt;h2&gt;</span>Billing<span class="nt">&lt;/h2&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"card"</span> <span class="na">type=</span><span class="s">"text"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;payment-form&gt;</span>
    <span class="nt">&lt;template</span> <span class="na">shadowrootmode=</span><span class="s">"open"</span><span class="nt">&gt;&lt;button&gt;</span>Pay<span class="nt">&lt;/button&gt;&lt;/template&gt;</span>
  <span class="nt">&lt;/payment-form&gt;</span>
<span class="nt">&lt;/section&gt;</span>
</code></pre></div></div>

<p>That section has two stories: the <strong>light DOM</strong> (the <code class="language-plaintext highlighter-rouge">&lt;section&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;h2&gt;</code>, and <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code>) and a <strong>shadow root</strong> (a private mini-DOM tree inside <code class="language-plaintext highlighter-rouge">&lt;payment-form&gt;</code> that ordinary selectors can’t see). Picture it as a building with a fenced-off inner courtyard:</p>

<pre><code class="language-mermaid">flowchart TD
    classDef visible fill:#1e293b,stroke:#9ca3b8,color:#f0f2f7,stroke-width:1px
    classDef wall fill:none,stroke:#f87171,stroke-width:3px,stroke-dasharray:4 4
    classDef target fill:#0ea5c7,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px

    S["section&lt;br&gt;(light DOM)"]:::visible
    H["h2 — Billing"]:::visible
    I["input[name='card']"]:::visible
    P["payment-form&lt;br&gt;━━━━━━━━━━━━&lt;br&gt; shadow root wall"]:::visible
    Pay["button — Pay&lt;br&gt;(inside shadow root)"]:::target

    S --&gt; H
    S --&gt; I
    S --&gt; P
    P -. "&gt;&gt;&gt;&amp;nbsp;(pierce&amp;nbsp;the&amp;nbsp;wall)" .-&gt; Pay
    linkStyle 3 stroke:#f87171,stroke-width:2px,color:#f87171
</code></pre>

<p>Now read those selectors like English:</p>

<blockquote>
  <p><strong>Plain English walk-through</strong></p>
  <ul>
    <li><strong><code class="language-plaintext highlighter-rouge">section:has(h2)</code></strong> (<strong>C2</strong>) — <em>“find a <code class="language-plaintext highlighter-rouge">&lt;section&gt;</code>, but only if it contains an <code class="language-plaintext highlighter-rouge">&lt;h2&gt;</code> somewhere inside.”</em> The <code class="language-plaintext highlighter-rouge">:has(...)</code> pseudo-class is CSS Level 4’s “parent selector” — the only way in CSS to ask <em>“does this element have a matching child?”</em> It’s the reverse-trip answer to “I know the heading, where’s its section?”</li>
    <li><strong><code class="language-plaintext highlighter-rouge">form:has(input[name='card'])</code></strong> (<strong>C1</strong>) — <em>“find a <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code>, but validate it by checking that it contains an <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> whose name is <code class="language-plaintext highlighter-rouge">card</code>.”</em> Same reverse trick, different starting tag.</li>
    <li><strong><code class="language-plaintext highlighter-rouge">page.locator('payment-form').locator('&gt;&gt;&gt; button')</code></strong> (<strong>X3</strong>) — <em>“go to <code class="language-plaintext highlighter-rouge">&lt;payment-form&gt;</code>, then <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> pierces the shadow boundary to find a <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code> inside the shadow tree.”</em> The <strong>shadow root</strong> is a private mini-DOM that the web component owns; CSS rules and most XPaths stop at the boundary like a fence, but <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> is Playwright’s crowbar. The Selenium equivalent requires <code class="language-plaintext highlighter-rouge">(SearchContext) shadowRoot.findElement(...)</code> after <code class="language-plaintext highlighter-rouge">getShadowRoot()</code> — more ceremony, same result.</li>
  </ul>

  <p>Net reading: <em>“Find the Billing section, take its card input, then click the Pay button even though Pay lives behind a shadow-root fence.”</em></p>
</blockquote>

<p>Locator forms:</p>

<table>
  <thead>
    <tr>
      <th>Form</th>
      <th>Snippet</th>
      <th>Source row</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>XPath</strong></td>
      <td><code class="language-plaintext highlighter-rouge">//section[.//h2[normalize-space()='Billing']]//input[@name='card']</code></td>
      <td>C2</td>
    </tr>
    <tr>
      <td><strong>CSS</strong></td>
      <td><code class="language-plaintext highlighter-rouge">section:has(h2[data-billing])</code> · <code class="language-plaintext highlighter-rouge">form:has(input[name='card'])</code></td>
      <td>C1, C2</td>
    </tr>
    <tr>
      <td><strong>Playwright engine</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('section').filter({ has: page.getByRole('heading', { name: 'Billing' }) })</code> · <code class="language-plaintext highlighter-rouge">page.locator('payment-form').locator('&gt;&gt;&gt; button')</code> (shadow piercing)</td>
      <td>C1, C2, X3</td>
    </tr>
  </tbody>
</table>

<p>Page Object (TS):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">SettingsPage</span> <span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">billingSection</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">cardInput</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">paymentFormRoot</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">payButton</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">page</span> <span class="o">=</span> <span class="nx">page</span><span class="p">;</span>

    <span class="c1">// C2: section-by-aria-heading via filter({ has })</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">billingSection</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">section</span><span class="dl">"</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span>
      <span class="na">has</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByRole</span><span class="p">(</span><span class="dl">"</span><span class="s2">heading</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Billing</span><span class="dl">"</span> <span class="p">}),</span>
    <span class="p">});</span>
    <span class="c1">// C1: form-by-input via :has() equivalent</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">cardInput</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">billingSection</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='card']</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// X3: payment-form lives in shadow DOM. &gt;&gt;&gt; pierces open shadow roots.</span>
    <span class="c1">// Pure XPath cannot reach across the boundary — fall back to a chained selector.</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">paymentFormRoot</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">payment-form</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">payButton</span>       <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">paymentFormRoot</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">&gt;&gt;&gt; button</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">enterCardAndPay</span><span class="p">(</span><span class="nx">cardNumber</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">cardInput</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="nx">cardNumber</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">payButton</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span>          <span class="c1">// pierces shadow root automatically</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Spec snippet — confirms both the cross-section reverse (<code class="language-plaintext highlighter-rouge">C1 + C2</code>) and the shadow-DOM pay action (<code class="language-plaintext highlighter-rouge">X3</code>):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SettingsPage</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./pages/SettingsPage</span><span class="dl">"</span><span class="p">;</span>

<span class="nx">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="dl">"</span><span class="s2">Scenario 3 · reverse tree + shadow DOM (C1 + C2 + F3 + X3)</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">test</span><span class="p">(</span><span class="dl">"</span><span class="s2">card input inside the Billing section pays through shadow root</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">sp</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">SettingsPage</span><span class="p">(</span><span class="nx">page</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/settings</span><span class="dl">"</span><span class="p">);</span>  <span class="c1">// illustrative</span>
    <span class="k">await</span> <span class="nx">sp</span><span class="p">.</span><span class="nx">enterCardAndPay</span><span class="p">(</span><span class="dl">"</span><span class="s2">4111111111111111</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">sp</span><span class="p">.</span><span class="nx">paymentFormRoot</span><span class="p">).</span><span class="nx">toContainText</span><span class="p">(</span><span class="sr">/paid/i</span><span class="p">);</span>
  <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<blockquote>
  <p><strong>Why <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt; button</code>?</strong> XPath stops at the shadow boundary; <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> is Playwright’s piercing chain. The Selenium equivalent requires <code class="language-plaintext highlighter-rouge">((SearchContext) shadowRoot).findElement(...)</code> after <code class="language-plaintext highlighter-rouge">getShadowRoot()</code> (cheatsheet §11.3). For Cypress, this scenario is the most common <em>reason Cypress tests go flaky</em> — they assume shadow-rooted components render transparently.</p>
</blockquote>

<p><strong>Verdict:</strong> for the reverse trip (<code class="language-plaintext highlighter-rouge">C1 + C2</code>), prefer Playwright’s <code class="language-plaintext highlighter-rouge">filter({ has: getByRole(...) })</code> — it expresses ARIA intent and survives class-name churn. For the <code class="language-plaintext highlighter-rouge">X3</code> shadow-DOM payment, keep the <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> chain in your POM; never try a “clever” XPath that quietly returns 0.</p>

<h3 id="scenario-4--admin-settings-panel-d5--e5--f4--b7">Scenario 4 — Admin settings panel: D5 + E5 + F4 + B7</h3>

<p>A composite fixture that exercises the four A–F rows the prior scenarios skipped: <strong>D5</strong> (compound OR via <code class="language-plaintext highlighter-rouge">:is()</code>), <strong>E5</strong> (general-sibling via <code class="language-plaintext highlighter-rouge">~</code>), <strong>F4</strong> (ARIA chain via CSS), <strong>B7</strong> (count-based structural nth ≥ 6). One DOM, four independent queries — a stress test for “every row in A–F has a fixture.”</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;section&gt;</span>
  <span class="nt">&lt;h2&gt;</span>Account settings<span class="nt">&lt;/h2&gt;</span>

  <span class="nt">&lt;form</span> <span class="na">id=</span><span class="s">"save-form"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"email"</span> <span class="na">type=</span><span class="s">"email"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"password"</span> <span class="na">type=</span><span class="s">"password"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"confirm"</span> <span class="na">type=</span><span class="s">"password"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Save changes"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"button"</span> <span class="na">value=</span><span class="s">"Cancel"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;/form&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"tree"</span> <span class="na">aria-label=</span><span class="s">"Notification preferences"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"treeitem"</span> <span class="na">aria-expanded=</span><span class="s">"true"</span> <span class="na">id=</span><span class="s">"email-prefs"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;span&gt;</span>Email notifications<span class="nt">&lt;/span&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"group"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"treeitem"</span> <span class="na">aria-expanded=</span><span class="s">"false"</span><span class="nt">&gt;</span>Marketing<span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"treeitem"</span> <span class="na">aria-expanded=</span><span class="s">"false"</span><span class="nt">&gt;</span>Security alerts<span class="nt">&lt;/div&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">role=</span><span class="s">"treeitem"</span> <span class="na">aria-expanded=</span><span class="s">"false"</span> <span class="na">id=</span><span class="s">"sms-prefs"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;span&gt;</span>SMS notifications<span class="nt">&lt;/span&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="nt">&lt;table&gt;</span>
    <span class="nt">&lt;thead&gt;</span>
      <span class="nt">&lt;tr&gt;&lt;th&gt;</span>Date<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Event<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Source<span class="nt">&lt;/th&gt;&lt;th&gt;</span>IP<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Device<span class="nt">&lt;/th&gt;&lt;th&gt;</span>Country<span class="nt">&lt;/th&gt;&lt;/tr&gt;</span>
    <span class="nt">&lt;/thead&gt;</span>
    <span class="nt">&lt;tbody&gt;</span>
      <span class="nt">&lt;tr&gt;&lt;td&gt;</span>2026-01-15<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Login<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Web<span class="nt">&lt;/td&gt;&lt;td&gt;</span>10.0.0.1<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Chrome<span class="nt">&lt;/td&gt;&lt;td&gt;</span>US<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
      <span class="nt">&lt;tr&gt;&lt;td&gt;</span>2026-02-22<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Password change<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Web<span class="nt">&lt;/td&gt;&lt;td&gt;</span>10.0.0.1<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Chrome<span class="nt">&lt;/td&gt;&lt;td&gt;</span>US<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
      <span class="nt">&lt;tr&gt;&lt;td&gt;</span>2026-03-08<span class="nt">&lt;/td&gt;&lt;td&gt;</span>2FA reset<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Mobile<span class="nt">&lt;/td&gt;&lt;td&gt;</span>10.0.0.2<span class="nt">&lt;/td&gt;&lt;td&gt;</span>iOS Safari<span class="nt">&lt;/td&gt;&lt;td&gt;</span>UK<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
      <span class="nt">&lt;tr&gt;&lt;td&gt;</span>2026-04-11<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Login<span class="nt">&lt;/td&gt;&lt;td&gt;</span>Tablet<span class="nt">&lt;/td&gt;&lt;td&gt;</span>10.0.0.3<span class="nt">&lt;/td&gt;&lt;td&gt;</span>iPad<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
    <span class="nt">&lt;/tbody&gt;</span>
  <span class="nt">&lt;/table&gt;</span>
<span class="nt">&lt;/section&gt;</span>
</code></pre></div></div>

<p>Four overlapping queries on one DOM — color the matches by rule so a reader can see, at a glance, which node satisfies which rule:</p>

<pre><code class="language-mermaid">flowchart TD
    classDef base fill:#1e293b,stroke:#9ca3b8,color:#f0f2f7,stroke-width:1px
    classDef d5 fill:#34d399,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px
    classDef e5 fill:#a78bfa,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px
    classDef f4 fill:#fbbf24,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px
    classDef b7 fill:#0ea5c7,stroke:#fff,color:#fff,font-weight:bold,stroke-width:2px

    Sec["section — Account settings"]:::base
    F["form#save-form"]:::base
    Em["input[name='email']&lt;br&gt;(anchor)"]:::base
    Pw["input[name='password']"]:::e5
    Co["input[name='confirm']"]:::e5
    Save["input[type='submit']&lt;br&gt;(Save changes)"]:::d5
    Cancel["input[type='button']&lt;br&gt;(Cancel)"]:::d5

    Tree["div[role='tree']"]:::base
    EmailTree["div[role='treeitem']&lt;br&gt;aria-expanded='true'&lt;br&gt;(Email prefs)"]:::f4
    SmsTree["div[role='treeitem']&lt;br&gt;aria-expanded='false'&lt;br&gt;(SMS prefs)"]:::base

    Tbl["table"]:::base
    R1["tr 1: 6 cells ✓"]:::b7
    R2["tr 2: 6 cells ✓"]:::b7
    R3["tr 3: 6 cells ✓"]:::b7
    R4["tr 4: 5 cells ✗"]:::base

    Sec --&gt; F
    F --&gt; Em
    F --&gt; Pw
    F --&gt; Co
    F --&gt; Save
    F --&gt; Cancel
    Sec --&gt; Tree
    Tree --&gt; EmailTree
    Tree --&gt; SmsTree
    Sec --&gt; Tbl
    Tbl --&gt; R1
    Tbl --&gt; R2
    Tbl --&gt; R3
    Tbl --&gt; R4
</code></pre>

<table>
  <thead>
    <tr>
      <th>Color</th>
      <th>Rule</th>
      <th>Source row</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Green</td>
      <td>OR-grouped action inputs</td>
      <td>D5</td>
    </tr>
    <tr>
      <td>Purple</td>
      <td>General-sibling inputs</td>
      <td>E5</td>
    </tr>
    <tr>
      <td>Amber</td>
      <td>Expanded treeitem</td>
      <td>F4</td>
    </tr>
    <tr>
      <td>Cyan</td>
      <td>Rows with a 6th child</td>
      <td>B7</td>
    </tr>
  </tbody>
</table>

<p>Locator forms (one per source row):</p>

<table>
  <thead>
    <tr>
      <th>Form</th>
      <th>Snippet</th>
      <th>Source row</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>XPath</strong></td>
      <td><code class="language-plaintext highlighter-rouge">//form//input[@type='submit' or @type='button']</code> · <code class="language-plaintext highlighter-rouge">//form//input[@name='email']/following-sibling::input</code> · <code class="language-plaintext highlighter-rouge">//*[@role='treeitem' and @aria-expanded='true']</code> · <code class="language-plaintext highlighter-rouge">//table//tbody/tr[count(td) &gt; 5]</code></td>
      <td>D5, E5, F4, B7</td>
    </tr>
    <tr>
      <td><strong>CSS</strong></td>
      <td><code class="language-plaintext highlighter-rouge">input:is([type='submit'], [type='button'])</code> · <code class="language-plaintext highlighter-rouge">input[name='email'] ~ input</code> · <code class="language-plaintext highlighter-rouge">div[role='treeitem'][aria-expanded='true']</code> · <code class="language-plaintext highlighter-rouge">tbody tr:where(:has(*:nth-child(6)))</code> (heuristic)</td>
      <td>D5, E5, F4, B7</td>
    </tr>
    <tr>
      <td><strong>Playwright engine</strong></td>
      <td><code class="language-plaintext highlighter-rouge">page.getByRole('button')</code> · <code class="language-plaintext highlighter-rouge">page.getByLabel('Email').locator('xpath=following-sibling::input')</code> · <code class="language-plaintext highlighter-rouge">page.getByRole('treeitem', { expanded: true })</code> · <code class="language-plaintext highlighter-rouge">page.locator('tbody tr').filter({ has: page.locator('td:nth-child(6)') })</code></td>
      <td>D5, E5, F4, B7</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Plain English walk-through — D5 (compound OR):</strong>
The form ends with two action inputs — one <code class="language-plaintext highlighter-rouge">&lt;input type="submit" value="Save changes"&gt;</code>, the other <code class="language-plaintext highlighter-rouge">&lt;input type="button" value="Cancel"&gt;</code>. CSS Level 4’s <code class="language-plaintext highlighter-rouge">:is()</code> pseudo-class is the cleanest OR-grouping: <code class="language-plaintext highlighter-rouge">input:is([type='submit'], [type='button'])</code> means <em>“any input whose type attribute is <code class="language-plaintext highlighter-rouge">submit</code> OR <code class="language-plaintext highlighter-rouge">button</code>”</em> — both action inputs, with specificity equal to the most-specific branch (no extra penalty). The XPath equivalent <code class="language-plaintext highlighter-rouge">//form//input[@type='submit' or @type='button']</code> is the original; <code class="language-plaintext highlighter-rouge">:is()</code> collapses it to one chain and reads better. <strong>Note:</strong> modern UIs more often use <code class="language-plaintext highlighter-rouge">&lt;button type="submit"&gt;</code> / <code class="language-plaintext highlighter-rouge">&lt;button type="button"&gt;</code> instead — the equivalent selector becomes <code class="language-plaintext highlighter-rouge">button:is([type='submit'], [type='button'])</code> and the same specificity rule applies. Use freely — this is the right tool for OR.</p>
</blockquote>

<blockquote>
  <p><strong>Plain English walk-through — E5 (general sibling):</strong>
The form has five <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> tags in a row — <code class="language-plaintext highlighter-rouge">email</code>, <code class="language-plaintext highlighter-rouge">password</code>, <code class="language-plaintext highlighter-rouge">confirm</code>, the <code class="language-plaintext highlighter-rouge">Save changes</code> action input, and the <code class="language-plaintext highlighter-rouge">Cancel</code> action input. The general-sibling combinator <code class="language-plaintext highlighter-rouge">~</code> says <em>“any sibling that comes after me”</em>, scoped by the tag class on its right (<code class="language-plaintext highlighter-rouge">~ input</code> = any <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> after me). So <code class="language-plaintext highlighter-rouge">input[name='email'] ~ input</code> matches <strong>all 4 sibling inputs</strong> after email — password, confirm, submit, button. <strong>This is the general-sibling nature of <code class="language-plaintext highlighter-rouge">~</code>:</strong> it doesn’t filter by attribute, just by tag. If you wanted only the password fields, narrow with another attribute: <code class="language-plaintext highlighter-rouge">input[name='email'] ~ input[type='password']</code> (matches password + confirm, 2 elements). Or for just <code class="language-plaintext highlighter-rouge">confirm</code>: <code class="language-plaintext highlighter-rouge">input[name='email'] ~ input[name='confirm']</code>. Relying on <code class="language-plaintext highlighter-rouge">~</code> requires predictable sibling types — if a hidden CSRF input or another text input crept in, it would also be matched. The XPath equivalent <code class="language-plaintext highlighter-rouge">//form//input[@name='email']/following-sibling::input</code> traverses the same axis and matches the same 4 elements.</p>
</blockquote>

<blockquote>
  <p><strong>Plain English walk-through — F4 (ARIA chain via CSS):</strong>
The notification tree has two top-level <code class="language-plaintext highlighter-rouge">&lt;div role="treeitem"&gt;</code> elements — <code class="language-plaintext highlighter-rouge">email-prefs</code> is <code class="language-plaintext highlighter-rouge">aria-expanded="true"</code> (open), <code class="language-plaintext highlighter-rouge">sms-prefs</code> is <code class="language-plaintext highlighter-rouge">aria-expanded="false"</code> (collapsed). The CSS chain <code class="language-plaintext highlighter-rouge">div[role='treeitem'][aria-expanded='true']</code> is the most direct attribute-style approach: <em>“any treeitem that is currently open.”</em> This is a place where Playwright’s engine selector wins cleanly: <code class="language-plaintext highlighter-rouge">page.getByRole('treeitem', { expanded: true })</code> says the same intent in one expression, exposes the ARIA semantics to the test, and survives attribute renames. The note in the F4 row says <em>“ARIA chain via CSS — verbose vs Playwright ARIA”</em> — that’s the trade-off: CSS is portable, engine selector is intent-clear.</p>
</blockquote>

<blockquote>
  <p><strong>Plain English walk-through — B7 (count-based structural nth):</strong>
The activity table has 4 body rows, three with 6 cells (Date, Event, Source, IP, Device, Country) and one with 5 cells (the 2026-04-11 row where Country is omitted). The selector <code class="language-plaintext highlighter-rouge">tbody tr:where(:has(*:nth-child(6)))</code> is the <em>heuristic</em> CSS form of <em>“rows that have a 6th child, scoped to the body”</em>. It matches the 3 wide rows and skips both the 5-cell row and the header row (which has 6 <code class="language-plaintext highlighter-rouge">&lt;th&gt;</code> cells but is excluded by the <code class="language-plaintext highlighter-rouge">tbody</code> prefix). The XPath equivalent <code class="language-plaintext highlighter-rouge">//table//tbody/tr[count(td) &gt; 5]</code> is the exact-intent version. <strong>The note in the B7 row is important:</strong> pure CSS cannot say “at least 6” — only “has a 6th child” — so for true ≥N semantics, measure in code (<code class="language-plaintext highlighter-rouge">page.locator('tr').filter({ has: page.locator('td:nth-child(7)') })</code> for ≥7, or simply check <code class="language-plaintext highlighter-rouge">.count()</code> in your spec). Count is a <em>measurement</em> problem, not a <em>selection</em> problem — preferring <code class="language-plaintext highlighter-rouge">.count()</code> over a clever selector avoids the brittleness of the CSS heuristic.</p>
</blockquote>

<p>Page Object (TS, constructor-body init per cheatsheet §10.1):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">SettingsPage</span> <span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">form</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">allActionButtons</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>     <span class="c1">// D5</span>
  <span class="k">readonly</span> <span class="nx">emailField</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">emailSiblings</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>         <span class="c1">// E5</span>
  <span class="k">readonly</span> <span class="nx">expandedTreeitems</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>     <span class="c1">// F4</span>
  <span class="k">readonly</span> <span class="nx">wideRows</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>              <span class="c1">// B7</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">page</span> <span class="o">=</span> <span class="nx">page</span><span class="p">;</span>

    <span class="k">this</span><span class="p">.</span><span class="nx">form</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">form#save-form</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// D5: any &lt;input type="submit"|"button"&gt; — both action inputs</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">allActionButtons</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input:is([type='submit'], [type='button'])</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// E5: every &lt;input&gt; sibling that follows the email input (4 elements: password, confirm, submit, button)</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">emailField</span>    <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='email']</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">emailSiblings</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='email'] ~ input</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// F4: every treeitem currently expanded (the email-prefs group, not sms-prefs)</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">expandedTreeitems</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">div[role='treeitem'][aria-expanded='true']</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// B7: heuristic scoped to &lt;tbody&gt; — rows that have a 6th child</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">wideRows</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">tbody tr:where(:has(*:nth-child(6)))</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">assertShape</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="c1">// D5: 2 action inputs</span>
    <span class="nx">expect</span><span class="p">(</span><span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">allActionButtons</span><span class="p">.</span><span class="nx">count</span><span class="p">()).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>

    <span class="c1">// E5: email has 4 &lt;input&gt; siblings (password, confirm, submit, button) — general-sibling matches all</span>
    <span class="nx">expect</span><span class="p">(</span><span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">emailSiblings</span><span class="p">.</span><span class="nx">count</span><span class="p">()).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">4</span><span class="p">);</span>

    <span class="c1">// F4: exactly 1 expanded treeitem</span>
    <span class="nx">expect</span><span class="p">(</span><span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">expandedTreeitems</span><span class="p">.</span><span class="nx">count</span><span class="p">()).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">expandedTreeitems</span><span class="p">).</span><span class="nx">toHaveId</span><span class="p">(</span><span class="dl">"</span><span class="s2">email-prefs</span><span class="dl">"</span><span class="p">);</span>

    <span class="c1">// B7: 3 rows have 6+ cells (row 4 has only 5)</span>
    <span class="nx">expect</span><span class="p">(</span><span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">wideRows</span><span class="p">.</span><span class="nx">count</span><span class="p">()).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">3</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Spec snippet — runs all four assertions in one test (a stress test for the appendix’s table-to-fixture coverage):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SettingsPage</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./pages/SettingsPage</span><span class="dl">"</span><span class="p">;</span>

<span class="nx">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="dl">"</span><span class="s2">Scenario 4 · admin settings (D5 + E5 + F4 + B7)</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">test</span><span class="p">(</span><span class="dl">"</span><span class="s2">shape, tree state, and wide rows match expectations</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">sp</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">SettingsPage</span><span class="p">(</span><span class="nx">page</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/settings</span><span class="dl">"</span><span class="p">);</span>  <span class="c1">// illustrative</span>
    <span class="k">await</span> <span class="nx">sp</span><span class="p">.</span><span class="nx">assertShape</span><span class="p">();</span>
  <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Verdict:</strong> D5’s <code class="language-plaintext highlighter-rouge">:is()</code> keeps specificity equivalent to the most-specific branch — use it freely. E5’s <code class="language-plaintext highlighter-rouge">~</code> is the <em>only</em> general-sibling combinator CSS offers, and it skips intervening siblings of any tag — invaluable for forms with mixed input types. F4’s CSS chain is verbose but portable; reach for <code class="language-plaintext highlighter-rouge">getByRole({ expanded: true })</code> when the runner supports it. B7 is a known weak spot — pure CSS cannot say “at least N” cleanly, so prefer measurement in code over a clever selector. The four patterns together show the breadth of the appendix: text-match, structural, ancestor, and count-based queries all have natural CSS equivalents <em>except</em> true count-based ≥N predicates.</p>

<h3 id="migration-snapshot--what-step-3-looks-like-in-a-real-pom">Migration snapshot — what step 3 looks like in a real POM</h3>

<p>The 5-step playbook says <em>“keep the XPath as a comment beside the new CSS/engine selector for a release cycle.”</em> The shape of the runtime is: a Playwright selector chain that <strong>tries the new CSS selector first</strong>, and <strong>silently falls back to the old, battle-hardened XPath</strong> if the CSS isn’t ready yet. Picture it as a retry-with-fallback:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant T as Test
    participant R as Playwright Resolver
    participant DOM as Browser DOM
    Note over T,DOM: locator chain = primary.or(legacy)

    T-&gt;&gt;R: click(locator)
    Note over R: Step 1 - try primary (CSS)
    R-&gt;&gt;DOM: resolve("button[type='submit']:not([disabled])")
    alt CSS resolves within 30s
        DOM--&gt;&gt;R: element
    else CSS times out
        DOM--&gt;&gt;R: nothing
        Note over R: Step 2 — try fallback (xpath=)
        R-&gt;&gt;DOM: resolve("xpath=//button[normalize-space()='Pay' …]")
        DOM--&gt;&gt;R: element
    end
    R--&gt;&gt;T: element resolved
    T-&gt;&gt;DOM: perform click()
</code></pre>

<p>The migration ledger is the comment block — <code class="language-plaintext highlighter-rouge">git grep "[LEGACY]"</code> should resolve to <strong>zero rows</strong> before closing the step-3 window. Concretely, for a single element:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">checkout</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">payButton</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span>
    <span class="p">[</span>
      <span class="c1">// LEGACY (kept through Q1 for rollback &amp; flake-diff):</span>
      <span class="c1">// "//button[normalize-space()='Pay' and not(@disabled)]"</span>
      <span class="c1">// .filter({ hasText: /^Pay$/i })</span>
      <span class="dl">"</span><span class="s2">button[type='submit']:not([disabled])</span><span class="dl">"</span>
    <span class="p">].</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2"> </span><span class="dl">"</span><span class="p">)</span>
  <span class="p">),</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Or, when the legacy form must remain executable for parallel runs (regression vs new):</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">checkout</span> <span class="o">=</span> <span class="p">{</span>
  <span class="c1">// STEP-3: dual-locator window — drop XPath line after ≥2 stable release cycles</span>
  <span class="na">payButton</span><span class="p">:</span> <span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">primary</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">button[type='submit']:not([disabled])</span><span class="dl">"</span><span class="p">;</span>      <span class="c1">// ship this</span>
    <span class="kd">const</span> <span class="nx">legacy</span>  <span class="o">=</span> <span class="dl">"</span><span class="s2">xpath=//button[normalize-space()='Pay' and not(@disabled)]</span><span class="dl">"</span><span class="p">;</span>
    <span class="k">return</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="nx">primary</span><span class="p">).</span><span class="nx">or</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="nx">legacy</span><span class="p">));</span>        <span class="c1">// chain: CSS first, XPath fallback</span>
  <span class="p">})(),</span>
<span class="p">};</span>
</code></pre></div></div>

<p>In Selenium 4, the same intent lands as a <code class="language-plaintext highlighter-rouge">By.cssSelector(...)</code> paired with a <code class="language-plaintext highlighter-rouge">By.xpath(...)</code> helper for the regression suite only — both fire in parallel, the green one wins, the red one stays in the report as <code class="language-plaintext highlighter-rouge">[LEGACY] Pay button via xpath</code>. The XPath comment line is the migration ledger — <code class="language-plaintext highlighter-rouge">git grep "[LEGACY]"</code> should resolve to zero rows before closing the step-3 window.</p>

<p><strong>Scenario-to-cheatsheet cross-reference.</strong> Each scenario exercises a specific slice of the cheatsheet — so a search hit on any §-anchor in the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">cheatsheet</a> can jump back to the worked example that demonstrates it in context:</p>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Cheatsheet §-refs crossed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Scenario 1 — Login form</strong> (<code class="language-plaintext highlighter-rouge">A2 + E1 + F1</code>)</td>
      <td><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#5-predicate-recipes">§5 Predicate recipes</a> (text-match + <code class="language-plaintext highlighter-rouge">and</code>/<code class="language-plaintext highlighter-rouge">not</code>); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#10-sdet-playbook-pom-waits-ci-observability">§10.1 POM placement</a> (locator-as-property); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.6 State pseudos</a> (<code class="language-plaintext highlighter-rouge">:not([disabled])</code>)</td>
    </tr>
    <tr>
      <td><strong>Scenario 2 — Dynamic striped table</strong> (<code class="language-plaintext highlighter-rouge">A4 + B3 + D8 + F5</code>)</td>
      <td><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#4-10-functions-youll-actually-use">§4 10 functions</a> (<code class="language-plaintext highlighter-rouge">starts-with</code>, <code class="language-plaintext highlighter-rouge">contains</code>, <code class="language-plaintext highlighter-rouge">translate()</code>); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#5-predicate-recipes">§5 Predicate recipes</a> (position); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.5 :nth-child formulas</a>; <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.6 Case-insensitive flag</a> (<code class="language-plaintext highlighter-rouge">[attr*='x' i]</code>)</td>
    </tr>
    <tr>
      <td><strong>Scenario 3 — Reverse-tree ARIA + shadow DOM</strong> (<code class="language-plaintext highlighter-rouge">C1 + C2 + X3</code>)</td>
      <td><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#3-the-13-axes">§3 The 13 axes</a> (ancestor axis); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.3 iframe + shadow DOM</a> (<code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> piercing); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.4 Modern CSS <code class="language-plaintext highlighter-rouge">:has()</code>/<code class="language-plaintext highlighter-rouge">:is()</code>/<code class="language-plaintext highlighter-rouge">:where()</code></a> (reverse parent)</td>
    </tr>
    <tr>
      <td><strong>Migration snapshot — step 3 dual-locator</strong></td>
      <td><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#4-10-functions-youll-actually-use">§4⚠️ XPath 1.0 discipline</a> (<code class="language-plaintext highlighter-rouge">translate()</code> over <code class="language-plaintext highlighter-rouge">lower-case()</code>); <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#10-sdet-playbook-pom-waits-ci-observability">§10.1 POM placement</a>; <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#10-sdet-playbook-pom-waits-ci-observability">§10.6 Browser matrix sanity</a></td>
    </tr>
    <tr>
      <td><strong>Scenario 4 — Admin settings panel</strong> (<code class="language-plaintext highlighter-rouge">D5 + E5 + F4 + B7</code>)</td>
      <td><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.4 Modern CSS <code class="language-plaintext highlighter-rouge">:has()</code>/<code class="language-plaintext highlighter-rouge">:is()</code>/<code class="language-plaintext highlighter-rouge">:where()</code></a> (<code class="language-plaintext highlighter-rouge">:is()</code> OR-grouping) · <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.7 Sibling combinators</a> (<code class="language-plaintext highlighter-rouge">~</code> general-sibling) · <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.9 Common complex-target patterns</a> (ARIA chains) · <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/#11-advanced-complex-patterns-svg-shadow-dom-modern-css">§11.1 Complex XPath / count-based nth</a> (B7 ≥ N children)</td>
    </tr>
  </tbody>
</table>

<p>Each anchor lands on the cheatsheet H2 section that contains the cited H3 — §10.1 sits at the very top of §10 (the SDET playbook), §11.3 / §11.4 / §11.5 / §11.6 are all sub-sections of §11 (Advanced and complex patterns). If you arrive from this matrix and need the <em>exact</em> H3, scroll one section in.</p>

<hr />

<blockquote>
  <p><strong>Bottom line of these four scenarios:</strong> CSS + Playwright engine selectors cover ~80% of the rows in tables A–F; the X1–X7 catalog documents the remaining ~20%. Together, <strong>Scenarios 1–4 exercise 12 of the 41 rows</strong> in tables A–F (A2, A4, B3, B7, C1, C2, D5, D8, E1, E5, F1, F4) — the unbacked rows (A1, A3, A5; B1, B2, B4–B6, B8; C3–C5; D1–D4, D6, D7, D9; E2–E4, E6, E7; F2, F3, F5–F7) are the natural backlog for <strong>Scenarios 5+</strong>. §12 of the <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">article</a> + §11 of the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">cheatsheet</a> carry the full syntax tree. Use this section as your <em>worked-example audit surface</em>: when a new locator shows up in code review, drop it alongside table rows + scenarios and ask “which row does this match?” — and which scenario-like fixture proves it survives?</p>
</blockquote>

<h2 id="5-step-selenium-xpath--playwright-cssengine-migration-playbook">5-step Selenium XPath → Playwright CSS/engine migration playbook</h2>

<p>When the goal is <em>“make this XPath safer / more portable”</em>, follow this order — don’t skip steps:</p>

<ol>
  <li><strong>Audit + classify.</strong> For every locator in your POM, tag as text-match / structural-nth / ancestor-reverse / attribute-predicate / state-driven / shadow-or-iframe / no-equivalent. <strong>~80% fall in the first four.</strong></li>
  <li><strong>Pick the rewrite target.</strong> Use tables A–F as the bridge. Engine selectors (<code class="language-plaintext highlighter-rouge">role=</code>, <code class="language-plaintext highlighter-rouge">text=</code>) are first preference for text + ARIA; CSS for structural-nth + forward attribute predicates; XPath remains for the X1–X7 catalog.</li>
  <li><strong>Translate + parallel commit.</strong> In one PR, keep the XPath as a comment beside the new CSS/engine selector for a release cycle. Avoid sweeping renames — shadow migration is how flakes appear.</li>
  <li><strong>Cross-browser walk.</strong> Run the suite on Chromium + Firefox + WebKit (Playwright) or Chrome + Firefox (Selenium 4) before deleting any XPath. Known gaps:
    <ul>
      <li>Safari ≤15.3 silently fails on <code class="language-plaintext highlighter-rouge">:has()</code></li>
      <li>Firefox on <code class="language-plaintext highlighter-rouge">:is()</code> specificity computation differs from Chrome in L4 draft forms</li>
      <li>WebKit SVG attribute selectors lag Chromium by ~6 mo in stable releases</li>
    </ul>
  </li>
  <li><strong>Deprecate.</strong> Once stable for ≥2 release cycles, drop the XPath comment line. Capture any remaining XPaths in a “fallback” module — patterns in X1–X7 stay XPath forever; document them with §11.1 anchor references.</li>
</ol>

<h2 id="common-translation-pitfalls">Common translation pitfalls</h2>

<p><em>Don’t make these mistakes when migrating:</em></p>

<ul>
  <li><strong>Dropping intent.</strong> <code class="language-plaintext highlighter-rouge">:nth-child(3)</code> and <code class="language-plaintext highlighter-rouge">position() = 3</code> look identical, but the first survives CSS refactors; the second breaks the moment something is inserted. Keep the count-based intent in CSS.</li>
  <li><strong>Replacing <code class="language-plaintext highlighter-rouge">starts-with(@class,'btn-')</code> with <code class="language-plaintext highlighter-rouge">[class^='btn-']</code></strong> — works for class match, but <code class="language-plaintext highlighter-rouge">class="btn-group btn-primary"</code> will still match. Use the space-padded-concat idiom <strong>only in XPath</strong>, OR use a stable <code class="language-plaintext highlighter-rouge">data-testid</code> instead.</li>
  <li><strong>Treating <code class="language-plaintext highlighter-rouge">:has()</code> as fully portable.</strong> It works in Playwright + Cypress 13+ but <strong>Safari ≤15.3 silently fails</strong> — strategic fallback XPath needed.</li>
  <li><strong>Forgetting state vs attribute.</strong> <code class="language-plaintext highlighter-rouge">:disabled</code> and <code class="language-plaintext highlighter-rouge">[disabled]</code> are <em>similar</em> but not identical. CSS <code class="language-plaintext highlighter-rouge">:disabled</code> evaluates the property’s effective state (including form inheritance); <code class="language-plaintext highlighter-rouge">[disabled]</code> matches the literal attribute presence. Pick by semantic intent.</li>
  <li><strong>Ignoring specificity in <code class="language-plaintext highlighter-rouge">:is()</code>.</strong> <code class="language-plaintext highlighter-rouge">:is()</code> keeps the most-specific branch’s specificity; <code class="language-plaintext highlighter-rouge">:where()</code> zeroes it. Choose by downstream override needs.</li>
</ul>

<h2 id="cross-references">Cross-references</h2>

<ul>
  <li><strong>For the broader XPath reference</strong> (every pattern in this card is documented there): <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">XPath Cheatsheet §1–§11 (Jul 2026)</a>.</li>
  <li><strong>For the mental-model + narrative explanation</strong> of why XPath/CSS/engine selectors separate the way they do: <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation (Jul 2026)</a> — §3 axes, §5 functions, §12.5 <code class="language-plaintext highlighter-rouge">:has()</code>/<code class="language-plaintext highlighter-rouge">:is()</code>, §12.8 decision flowchart.</li>
  <li><strong>For boundary-crossing patterns</strong> (X1–X3 above): §12.4 of the article + §11.3 of the cheatsheet.</li>
  <li><strong>For the broader migration thesis</strong> (BiDi replacing WebDriver): <a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">Selenium BiDi vs Playwright CDP (Jul 2026)</a>.</li>
  <li><strong>For worked-through DOM → POM → spec chains</strong> that exercise the rows in tables A–F (Scenario 1 login, Scenario 2 striped table, Scenario 3 reverse-tree ARIA, plus a migration-step-3 snapshot): see the <a href="#worked-through-usage-examples">Worked-through usage examples</a> section above — that block converts the tables into runnable Playwright TypeScript + Selenium Java.</li>
</ul>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://www.w3.org/TR/selectors-4/">CSS Selectors Level 4 — W3C working draft</a> — <code class="language-plaintext highlighter-rouge">:has()</code>, <code class="language-plaintext highlighter-rouge">:is()</code>, <code class="language-plaintext highlighter-rouge">:where()</code>, attribute equality flag (<code class="language-plaintext highlighter-rouge">[attr=value i]</code>)</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/XPath/Comparison_with_CSS_selectors">MDN — XPath ↔ CSS comparison</a> — official translator</li>
  <li><a href="https://playwright.dev/docs/other-locators">Playwright selectors — official</a> — engine selectors (<code class="language-plaintext highlighter-rouge">role=</code>, <code class="language-plaintext highlighter-rouge">text=</code>, <code class="language-plaintext highlighter-rouge">near=</code>, <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code>, <code class="language-plaintext highlighter-rouge">nth=</code>)</li>
  <li><a href="https://devhints.io/xpath">devhints — XPath</a> — the inspiration pattern this card follows</li>
  <li><a href="https://caniuse.com/css-has">caniuse — CSS <code class="language-plaintext highlighter-rouge">:has()</code></a> — browser availability (Safari 15.4+, FF 121+, Chrome 105+)</li>
</ol>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">XPath Cheatsheet for Test Automation Engineers (Jul 2026)</a> · <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation (Jul 2026)</a> — the cards this appendix sits beside.</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="tools" /><category term="reference" /><category term="xpath" /><category term="css-selectors" /><category term="translation" /><category term="migration" /><category term="selenium" /><category term="playwright" /><category term="cypress" /><category term="sdet" /><category term="page-object-model" /><category term="complex-xpath" /><category term="complex-css" /><category term="xpath-2-css" /><category term="css-2-xpath" /><category term="intermediate" /><category term="advanced" /><summary type="html"><![CDATA[A compact, one-page XPath 1.0 ↔ CSS 2/3/4 translation reference for SDETs migrating Selenium suites to Playwright engine selectors. Text match, structural nth, ancestor reverse, attribute predicates, boolean combinations, sibling combinators — with a 'no clean translation exists' catalog and a 5-step migration playbook.]]></summary></entry><entry><title type="html">XPath Cheatsheet for Test Automation Engineers</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/" rel="alternate" type="text/html" title="XPath Cheatsheet for Test Automation Engineers" /><published>2026-07-13T00:00:00+00:00</published><updated>2026-07-13T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/"><![CDATA[<p><strong>Bookmark this. Seriously.</strong> When you have 2 hours before a release and need to find that one stubborn element that broke your test, you’ll be back here.</p>

<p>This is the cheatsheet version of <a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation: From "I Hate This" to "I Write It in My Sleep"</a>. No stories, no philosophy — just copy-paste patterns that work. Every expression here was born from “why doesn’t this <em>#$%</em> element select?” moments at 11pm.</p>

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-locator-priority-pyramid">Locator priority pyramid</a></li>
  <li><a href="#2-five-syntax-patterns-you-must-memorize">Five syntax patterns you must memorize</a></li>
  <li><a href="#3-the-13-axes">The 13 axes</a></li>
  <li><a href="#4-10-functions-youll-actually-use">10 functions you’ll actually use</a></li>
  <li><a href="#5-predicate-recipes">Predicate recipes</a></li>
  <li><a href="#6-common-target-patterns">Common-target patterns</a></li>
  <li><a href="#7-multi-language-code-samples">Multi-language code samples</a></li>
  <li><a href="#8-gotchas-per-framework">Gotchas per framework</a></li>
  <li><a href="#9-when-not-to-use-xpath">When NOT to use XPath</a></li>
  <li><a href="#10-sdet-playbook-pom-waits-ci-observability">SDET playbook — POM, waits, CI, observability</a></li>
  <li><a href="#11-advanced-complex-patterns-svg-shadow-dom-modern-css">Advanced &amp; complex patterns — SVG, shadow DOM, modern CSS</a></li>
</ol>

<hr />

<h2 id="1-locator-priority-pyramid">1. Locator priority pyramid</h2>

<p><strong>Story:</strong> I once wrote a beautiful XPath based on CSS class selectors. Worked for 6 months. Then CSS classes changed in a refactor, and 80 tests exploded the same day. I learned: <strong>priority matters.</strong></p>

<p>Reach for the top first. Descend only when you must. This pyramid exists so your selectors survive refactors.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🏆  data-testid              //*[@data-testid='checkout']
🥇  ARIA role + name         //button[@aria-label='Submit order']
🥈  Stable ID                //*[@id='user-email']
🥉  Stable attribute         //input[@name='email']
4️⃣  Stable CSS selector      #checkout-form .submit
5️⃣  Relative XPath           //form[@id='checkout']//button[normalize-space()='Pay']
6️⃣  Absolute XPath           ⛔ avoid in tests
7️⃣  Indexed XPath            ⛔ avoid in tests (//ul/li[3]/button)
</code></pre></div></div>

<hr />

<h2 id="2-five-syntax-patterns-you-must-memorize">2. Five syntax patterns you must memorize</h2>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">tag</span><span class="p">[</span><span class="na">@attr</span><span class="o">=</span><span class="s">'value'</span><span class="p">]</span><span class="w">                          </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">attr</span><span class="w"> </span><span class="nt">equality</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@attr</span><span class="o">,</span><span class="w"> </span><span class="s">'sub'</span><span class="p">)]</span><span class="w">                   </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">substring</span><span class="w"> </span><span class="nt">attr</span><span class="w"> </span><span class="nt">match</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">tag</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Visible Text'</span><span class="p">]</span><span class="w">       </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">exact</span><span class="w"> </span><span class="nt">visible</span><span class="w"> </span><span class="nt">text</span><span class="o">,</span><span class="w"> </span><span class="nt">whitespace-safe</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">tag</span><span class="p">[</span><span class="k">text</span><span class="p">()</span><span class="o">=</span><span class="s">'Exact'</span><span class="p">]</span><span class="w">                          </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">exact</span><span class="w"> </span><span class="nt">text</span><span class="w"> </span><span class="k">node</span><span class="w"> </span><span class="p">(</span><span class="nt">no</span><span class="w"> </span><span class="nt">descendants</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ancestor</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'x'</span><span class="p">]</span><span class="o">//</span><span class="nt">descendant</span><span class="p">[</span><span class="na">@name</span><span class="o">=</span><span class="s">'y'</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">relationship</span><span class="w"> </span><span class="nt">chain</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<p>Everything else is composition.</p>

<hr />

<h2 id="3-the-13-axes">3. The 13 axes</h2>

<p>The full family. In practice you use 5; the others are there when you need them.</p>

<table>
  <thead>
    <tr>
      <th>Axis</th>
      <th>Abbrev</th>
      <th>Direction</th>
      <th>Reach</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">child</code></td>
      <td><code class="language-plaintext highlighter-rouge">.</code> (relative path) / direct slash</td>
      <td>down</td>
      <td>one level</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">descendant</code></td>
      <td>(no abbrev, use nested predicates)</td>
      <td>down</td>
      <td>any depth</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">descendant-or-self</code></td>
      <td><code class="language-plaintext highlighter-rouge">//</code></td>
      <td>down</td>
      <td>any depth + self</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">parent</code></td>
      <td><code class="language-plaintext highlighter-rouge">..</code></td>
      <td>up</td>
      <td>one level</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ancestor</code></td>
      <td>—</td>
      <td>up</td>
      <td>any level</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ancestor-or-self</code></td>
      <td>—</td>
      <td>up</td>
      <td>any level + self</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">following-sibling</code></td>
      <td>—</td>
      <td>sideways (right)</td>
      <td>same level, after</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">preceding-sibling</code></td>
      <td>—</td>
      <td>sideways (left)</td>
      <td>same level, before</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">following</code></td>
      <td>—</td>
      <td>right</td>
      <td>any depth, after</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">preceding</code></td>
      <td>—</td>
      <td>left</td>
      <td>any depth, before</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">self</code></td>
      <td><code class="language-plaintext highlighter-rouge">.</code></td>
      <td>none</td>
      <td>the node itself</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attribute</code></td>
      <td><code class="language-plaintext highlighter-rouge">@</code></td>
      <td>sideways</td>
      <td>attribute of node</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">namespace</code></td>
      <td>—</td>
      <td>sideways</td>
      <td>namespace of node</td>
    </tr>
  </tbody>
</table>

<h3 id="visual-axes-map">Visual axes map</h3>

<pre><code class="language-mermaid">flowchart TD
    classDef ctx fill:#0ea5c7,color:#fff,stroke:#fff,stroke-width:2px
    CTC["context node ◉"]:::ctx
    CTC --&gt;|"child|."| CH["↓ child"]
    CTC --&gt;|"//"| DESC["↓↘ descendant"]
    CTC --&gt;|".."| PAR["↑ parent"]
    CTC --&gt;|"ancestor"| ANC["↑↗ ancestor"]
    CTC --&gt;|"following-sibling"| FS["→ following-sibling"]
    CTC --&gt;|"preceding-sibling"| PS["← preceding-sibling"]
    CTC --&gt;|"@"| ATTR["⇆ @attribute"]
</code></pre>

<h3 id="shortcut-glossary">Shortcut glossary</h3>

<table>
  <thead>
    <tr>
      <th>Long form</th>
      <th>Shortcut</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">child::div</code></td>
      <td><code class="language-plaintext highlighter-rouge">div</code></td>
      <td><code class="language-plaintext highlighter-rouge">./div</code> (children named <code class="language-plaintext highlighter-rouge">div</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">descendant-or-self::node()</code></td>
      <td><code class="language-plaintext highlighter-rouge">//</code></td>
      <td><code class="language-plaintext highlighter-rouge">//input</code> (any descendant)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">parent::node()</code></td>
      <td><code class="language-plaintext highlighter-rouge">..</code></td>
      <td><code class="language-plaintext highlighter-rouge">..</code> (one level up)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">self::node()</code></td>
      <td><code class="language-plaintext highlighter-rouge">.</code></td>
      <td><code class="language-plaintext highlighter-rouge">.</code> (the node itself)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attribute::href</code></td>
      <td><code class="language-plaintext highlighter-rouge">@href</code></td>
      <td>attribute selection</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4-10-functions-youll-actually-use">4. 10 functions you’ll actually use</h2>

<p>XPath ships with 120+ functions. You’ll touch these 10 in most work:</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>Purpose</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">text()</code></td>
      <td>Match exact text node</td>
      <td><code class="language-plaintext highlighter-rouge">//h1[text()='Welcome']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">contains(@a, 'sub')</code></td>
      <td>Substring match</td>
      <td><code class="language-plaintext highlighter-rouge">//a[contains(@href,'/orders/')]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">normalize-space()</code></td>
      <td>Trim + collapse whitespace</td>
      <td><code class="language-plaintext highlighter-rouge">//h1[normalize-space()='Welcome']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">starts-with(@a, 'pre')</code></td>
      <td>Prefix match</td>
      <td><code class="language-plaintext highlighter-rouge">//div[starts-with(@class,'order-')]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">translate(s, 'A..Z', 'a..z')</code></td>
      <td>Case-insensitive match (XPath 1.0 idiom)</td>
      <td><code class="language-plaintext highlighter-rouge">//a[translate(@href,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='/help']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">string-length()</code></td>
      <td>Length tests</td>
      <td><code class="language-plaintext highlighter-rouge">//input[string-length(@value)=0]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">not()</code></td>
      <td>Negate a condition</td>
      <td><code class="language-plaintext highlighter-rouge">//input[not(@disabled)]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">count()</code></td>
      <td>Count matches</td>
      <td><code class="language-plaintext highlighter-rouge">//tr[count(td)&gt;=5]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">position()</code> / <code class="language-plaintext highlighter-rouge">last()</code></td>
      <td>Positional access</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[last()]</code> · <code class="language-plaintext highlighter-rouge">//ul/li[position()=2]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">substring(s, start, len)</code></td>
      <td>Slicing strings</td>
      <td><code class="language-plaintext highlighter-rouge">//td[substring(text(),1,3)='INV']</code></td>
    </tr>
  </tbody>
</table>

<h3 id="combo-expressions-copy-paste-ready">Combo expressions (copy-paste ready)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">CSS</span><span class="w"> </span><span class="nt">class</span><span class="w"> </span><span class="nf">exactly</span><span class="w"> </span><span class="p">(</span><span class="nt">not</span><span class="w"> </span><span class="nt">just</span><span class="w"> </span><span class="nt">substring</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="nf">concat</span><span class="p">(</span><span class="s">' '</span><span class="o">,</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">(</span><span class="na">@class</span><span class="p">)</span><span class="o">,</span><span class="w"> </span><span class="s">' '</span><span class="p">)</span><span class="o">,</span><span class="w"> </span><span class="s">' btn-primary '</span><span class="p">)]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">visible</span><span class="w"> </span><span class="nt">text</span><span class="w"> </span><span class="nt">ignoring</span><span class="w"> </span><span class="nt">whitespace</span><span class="o">,</span><span class="w"> </span><span class="nt">ENABLED</span><span class="w"> </span><span class="nt">only</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Pay now'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Case-insensitive</span><span class="w"> </span><span class="nt">attribute</span><span class="w"> </span><span class="nf">match</span><span class="w"> </span><span class="p">(</span><span class="nt">XPath</span><span class="w"> </span><span class="mf">1.0</span><span class="w"> </span><span class="nt">only</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">a</span><span class="p">[</span><span class="nf">translate</span><span class="p">(</span><span class="na">@href</span><span class="o">,</span><span class="s">'ABCDEFGHIJKLMNOPQRSTUVWXYZ'</span><span class="o">,</span><span class="s">'abcdefghijklmnopqrstuvwxyz'</span><span class="p">)</span><span class="o">=</span><span class="s">'/help'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">repeated</span><span class="w"> </span><span class="nt">cell</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">row</span><span class="o">,</span><span class="w"> </span><span class="nt">by</span><span class="w"> </span><span class="nt">header</span><span class="w"> </span><span class="nt">label</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">th</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Total'</span><span class="p">]</span><span class="w"> </span><span class="ow">or</span><span class="w"> </span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Total'</span><span class="p">]]</span><span class="o">/</span><span class="nt">td</span><span class="p">[</span><span class="m">2</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">last</span><span class="w"> </span><span class="nt">item</span><span class="w"> </span><span class="nt">of</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">list</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">last</span><span class="p">()]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">First</span><span class="w"> </span><span class="nt">three</span><span class="w"> </span><span class="nt">items</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">position</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="m">3</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<blockquote>
  <p><strong>War story:</strong> I debugged for 3 hours why my XPath to select case-insensitive elements wasn’t working. I’d written: <code class="language-plaintext highlighter-rouge">//a[lower-case(@href)='/help']</code>. Looked right. Returned nothing. Silent failure — no error message, just empty results. Turns out, <code class="language-plaintext highlighter-rouge">lower-case()</code> is XPath 2.0, and browsers only support 1.0. I had to switch to <code class="language-plaintext highlighter-rouge">translate()</code> instead. Now I know: if you see 2.0 features in the docs, they’re silently broken in the browser. <strong>Use only XPath 1.0 or go server-side.</strong> WebDriver, Playwright, and Cypress all evaluate 1.0 for <code class="language-plaintext highlighter-rouge">By.xpath</code>/<code class="language-plaintext highlighter-rouge">xpath=…</code>/<code class="language-plaintext highlighter-rouge">cy.xpath()</code>.</p>
</blockquote>

<hr />

<h2 id="5-predicate-recipes">5. Predicate recipes</h2>

<h3 id="locate-by-visible-text">Locate by visible text</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Sign In'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="k">text</span><span class="p">()</span><span class="o">=</span><span class="s">'Sign In'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">starts-with</span><span class="p">(</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">,</span><span class="s">'Sign'</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-attribute-with-and">Locate by attribute (with and)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'checkbox'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@name</span><span class="o">=</span><span class="s">'agree'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">a</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'button'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-disabled</span><span class="o">=</span><span class="s">'false'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-attribute-with-or">Locate by attribute (with or)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'submit'</span><span class="w"> </span><span class="ow">or</span><span class="w"> </span><span class="na">@type</span><span class="o">=</span><span class="s">'button'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-absence">Locate by absence</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="nf">not</span><span class="p">(</span><span class="nf">contains</span><span class="p">(</span><span class="na">@class</span><span class="o">,</span><span class="s">'hidden'</span><span class="p">))]</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-position">Locate by position</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">)[</span><span class="m">1</span><span class="p">]</span><span class="w">                        </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">first</span><span class="w"> </span><span class="nt">li</span><span class="w"> </span><span class="nf">overall</span><span class="w"> </span><span class="p">(</span><span class="nt">parens</span><span class="w"> </span><span class="nt">matter</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="m">1</span><span class="p">]</span><span class="w">                           </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">first</span><span class="w"> </span><span class="nt">li</span><span class="w"> </span><span class="nt">of</span><span class="w"> </span><span class="nt">each</span><span class="w"> </span><span class="nt">ul</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">last</span><span class="p">()]</span><span class="w">                      </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">last</span><span class="w"> </span><span class="nt">li</span><span class="w"> </span><span class="nt">of</span><span class="w"> </span><span class="nt">each</span><span class="w"> </span><span class="nt">ul</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nf">position</span><span class="p">()</span><span class="w"> </span><span class="ow">mod</span><span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">0</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">even</span><span class="w"> </span><span class="nt">rows</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-count">Locate by count</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">table</span><span class="p">[</span><span class="nf">count</span><span class="p">(</span><span class="o">.//</span><span class="nt">tr</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="m">5</span><span class="p">]</span><span class="w">           </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">tables</span><span class="w"> </span><span class="nt">with</span><span class="w"> </span><span class="nt">more</span><span class="w"> </span><span class="nt">than</span><span class="w"> </span><span class="m">5</span><span class="w"> </span><span class="nt">rows</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="p">[</span><span class="nf">count</span><span class="p">(</span><span class="o">.//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'X'</span><span class="p">]])</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="m">0</span><span class="p">]</span><span class="w">   </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">tables</span><span class="w"> </span><span class="nt">containing</span><span class="w"> </span><span class="nt">cell</span><span class="w"> </span><span class="s">'X'</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-relationship">Locate by relationship</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">label</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Email'</span><span class="p">]</span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">input</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="na">@data-testid</span><span class="o">=</span><span class="s">'submit'</span><span class="p">]</span><span class="o">/</span><span class="k">parent</span><span class="o">::</span><span class="nt">form</span><span class="w">
</span><span class="o">//</span><span class="nt">section</span><span class="p">[</span><span class="o">.//</span><span class="nt">h2</span><span class="p">[</span><span class="k">text</span><span class="p">()</span><span class="o">=</span><span class="s">'Billing'</span><span class="p">]]</span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@name</span><span class="o">=</span><span class="s">'card'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="locate-by-structural-pattern">Locate by structural pattern</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@class</span><span class="o">=</span><span class="s">'row'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">count</span><span class="p">(</span><span class="o">./</span><span class="ow">div</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">3</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">rows</span><span class="w"> </span><span class="nt">with</span><span class="w"> </span><span class="nt">exactly</span><span class="w"> </span><span class="m">3</span><span class="w"> </span><span class="nt">children</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="p">[</span><span class="nt">li</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@hidden</span><span class="p">)]</span><span class="w">                   </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">visible</span><span class="w"> </span><span class="nt">non-empty</span><span class="w"> </span><span class="nt">lists</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="6-common-target-patterns">6. Common-target patterns</h2>

<h3 id="forms">Forms</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">form</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'login'</span><span class="p">]</span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@name</span><span class="o">=</span><span class="s">'username'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">form</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@action</span><span class="o">,</span><span class="s">'/checkout'</span><span class="p">)]</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'submit'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">label</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Email'</span><span class="p">]</span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">input</span><span class="w">
</span></code></pre></div></div>

<h3 id="tables">Tables</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'bob@x.com'</span><span class="p">]]</span><span class="o">/</span><span class="nt">td</span><span class="p">[</span><span class="m">4</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">row</span><span class="w"> </span><span class="nt">containing</span><span class="w"> </span><span class="nt">cell</span><span class="o">,</span><span class="w"> </span><span class="k">then</span><span class="w"> </span><span class="nt">column</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">thead</span><span class="o">/</span><span class="nt">tr</span><span class="o">/</span><span class="nt">th</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Status'</span><span class="p">]</span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">th</span><span class="p">[</span><span class="m">1</span><span class="p">]</span><span class="w">   </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">column</span><span class="w"> </span><span class="nt">header</span><span class="w"> </span><span class="nt">lookup</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nf">position</span><span class="p">()</span><span class="o">=</span><span class="m">1</span><span class="p">]</span><span class="o">/</span><span class="nt">td</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">first</span><span class="w"> </span><span class="nt">data</span><span class="w"> </span><span class="nt">row</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<h3 id="modals--dialogs">Modals &amp; dialogs</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'dialog'</span><span class="p">]</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Confirm'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@class</span><span class="o">,</span><span class="s">'modal'</span><span class="p">)</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="nf">contains</span><span class="p">(</span><span class="na">@class</span><span class="o">,</span><span class="s">'hidden'</span><span class="p">))]</span><span class="o">//</span><span class="nt">button</span><span class="w">
</span></code></pre></div></div>

<h3 id="repeating-rows-to-do-lists-cart-items">Repeating rows (to-do lists, cart items)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">ul</span><span class="p">[</span><span class="na">@class</span><span class="o">=</span><span class="s">'cart-items'</span><span class="p">]</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="o">.//</span><span class="nt">span</span><span class="p">[</span><span class="na">@class</span><span class="o">=</span><span class="s">'price'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="k">text</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s">'50'</span><span class="p">]]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">XPath</span><span class="w"> </span><span class="mf">2.0</span><span class="w"> </span><span class="nt">only</span><span class="o">,</span><span class="w"> </span><span class="nt">but</span><span class="w"> </span><span class="nt">commonly</span><span class="w"> </span><span class="nt">written</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="p">[</span><span class="na">@class</span><span class="o">=</span><span class="s">'cart-items'</span><span class="p">]</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="o">.,</span><span class="w"> </span><span class="s">'Premium'</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<blockquote>
  <p>Many “filter by content” patterns need XPath <strong>2.0+</strong> (Playwright’s <code class="language-plaintext highlighter-rouge">getByText</code>, Cypress’s <code class="language-plaintext highlighter-rouge">.contains()</code> are usually easier). Stick to XPath 1.0 expressions above unless you’ve explicitly opted in.</p>
</blockquote>

<h3 id="dynamic-ids-auto-generated">Dynamic IDs (auto-generated)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="nf">starts-with</span><span class="p">(</span><span class="na">@id</span><span class="o">,</span><span class="s">'react-select-'</span><span class="p">)</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-expanded</span><span class="o">=</span><span class="s">'true'</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">React-Select</span><span class="w"> </span><span class="nt">open</span><span class="w"> </span><span class="nt">dropdown</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="na">@aria-label</span><span class="o">=</span><span class="s">'Close'</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">dialog</span><span class="w"> </span><span class="nt">close</span><span class="w"> </span><span class="nt">button</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<h3 id="upload-widgets">Upload widgets</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@class</span><span class="o">,</span><span class="s">'upload'</span><span class="p">)]</span><span class="o">/</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'file'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">label</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Upload avatar'</span><span class="p">]</span><span class="o">/</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'file'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="drag-and-drop-handles">Drag-and-drop handles</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">li</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Item to drag'</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">source</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'target-list'</span><span class="p">]</span><span class="w">    </span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">destination</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="7-multi-language-code-samples">7. Multi-language code samples</h2>

<p>Same XPath. Five flavors of API.</p>

<h3 id="java--selenium">Java — Selenium</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.openqa.selenium.By</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.WebDriver</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.openqa.selenium.chrome.ChromeDriver</span><span class="o">;</span>

<span class="nc">WebDriver</span> <span class="n">driver</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ChromeDriver</span><span class="o">();</span>
<span class="n">driver</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"https://the-internet.herokuapp.com/login"</span><span class="o">);</span>

<span class="nc">WebElement</span> <span class="n">username</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span>
    <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//form[@id='login']//input[@id='username']"</span><span class="o">)</span>
<span class="o">);</span>
<span class="n">username</span><span class="o">.</span><span class="na">sendKeys</span><span class="o">(</span><span class="s">"tomsmith"</span><span class="o">);</span>
</code></pre></div></div>

<h3 id="python--selenium">Python — Selenium</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">selenium</span> <span class="kn">import</span> <span class="n">webdriver</span>
<span class="kn">from</span> <span class="nn">selenium.webdriver.common.by</span> <span class="kn">import</span> <span class="n">By</span>

<span class="n">driver</span> <span class="o">=</span> <span class="n">webdriver</span><span class="p">.</span><span class="n">Chrome</span><span class="p">()</span>
<span class="n">driver</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"https://the-internet.herokuapp.com/login"</span><span class="p">)</span>

<span class="n">username</span> <span class="o">=</span> <span class="n">driver</span><span class="p">.</span><span class="n">find_element</span><span class="p">(</span>
    <span class="n">By</span><span class="p">.</span><span class="n">XPATH</span><span class="p">,</span> <span class="s">"//form[@id='login']//input[@id='username']"</span>
<span class="p">)</span>
<span class="n">username</span><span class="p">.</span><span class="n">send_keys</span><span class="p">(</span><span class="s">"tomsmith"</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="typescript--javascript--playwright">TypeScript / JavaScript — Playwright</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">chromium</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">playwright</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">browser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">chromium</span><span class="p">.</span><span class="nx">launch</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">browser</span><span class="p">.</span><span class="nx">newPage</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/login</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// XPath — both work; 'xpath=' is more explicit</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">xpath=//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// Playwright semantic alternatives (preferred when possible)</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByLabel</span><span class="p">(</span><span class="dl">"</span><span class="s2">Username</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">#username</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="cypress">Cypress</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">cy</span><span class="p">.</span><span class="nx">visit</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://the-internet.herokuapp.com/login</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// Requires cypress-xpath plugin; otherwise stick to Cypress's built-in selectors</span>
<span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="dl">"</span><span class="s2">//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">).</span><span class="nx">type</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// Cypress-native equivalent is usually better</span>
<span class="nx">cy</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">#username</span><span class="dl">"</span><span class="p">).</span><span class="nx">type</span><span class="p">(</span><span class="dl">"</span><span class="s2">tomsmith</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="c--selenium">C# — Selenium</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">OpenQA.Selenium</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">OpenQA.Selenium.Chrome</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">driver</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ChromeDriver</span><span class="p">();</span>
<span class="n">driver</span><span class="p">.</span><span class="nf">Navigate</span><span class="p">().</span><span class="nf">GoToUrl</span><span class="p">(</span><span class="s">"https://the-internet.herokuapp.com/login"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">username</span> <span class="p">=</span> <span class="n">driver</span><span class="p">.</span><span class="nf">FindElement</span><span class="p">(</span>
    <span class="n">By</span><span class="p">.</span><span class="nf">XPath</span><span class="p">(</span><span class="s">"//form[@id='login']//input[@id='username']"</span><span class="p">)</span>
<span class="p">);</span>
<span class="n">username</span><span class="p">.</span><span class="nf">SendKeys</span><span class="p">(</span><span class="s">"tomsmith"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="side-by-side-comparison">Side-by-side comparison</h3>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Java</th>
      <th>Python</th>
      <th>TS/JS</th>
      <th>C#</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>By id</td>
      <td><code class="language-plaintext highlighter-rouge">By.id("x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.ID, "x"</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator("#x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.Id("x")</code></td>
    </tr>
    <tr>
      <td>By xpath</td>
      <td><code class="language-plaintext highlighter-rouge">By.xpath("//x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.XPATH, "//x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator("xpath=//x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.XPath("//x")</code></td>
    </tr>
    <tr>
      <td>By CSS</td>
      <td><code class="language-plaintext highlighter-rouge">By.cssSelector(".x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.CSS_SELECTOR, ".x"</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator(".x")</code></td>
      <td><code class="language-plaintext highlighter-rouge">By.CssSelector(".x")</code></td>
    </tr>
    <tr>
      <td>ARIA role</td>
      <td>(no built-in)</td>
      <td>(no built-in)</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByRole("button")</code></td>
      <td>(no built-in)</td>
    </tr>
    <tr>
      <td>Test-id</td>
      <td>(no built-in)</td>
      <td>(no built-in)</td>
      <td><code class="language-plaintext highlighter-rouge">page.getByTestId("x")</code></td>
      <td>(no built-in)</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="8-gotchas-per-framework">8. Gotchas per framework</h2>

<h3 id="selenium-java--python--c">Selenium (Java / Python / C#)</h3>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">NoSuchElementException</code> ≠ “element missing forever.”</strong> Could be iframe context wrong. Wrap with <code class="language-plaintext highlighter-rouge">driver.switchTo().frame(...)</code> or <code class="language-plaintext highlighter-rouge">.defaultContent()</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">findElement</code> vs <code class="language-plaintext highlighter-rouge">findElements</code>.</strong> Singular throws on empty; plural returns list. Use plural when 0/1/many is acceptable.</li>
  <li><strong>Implicit waits stack across tests.</strong> Always reset in <code class="language-plaintext highlighter-rouge">@BeforeEach</code> or your setup hook.</li>
  <li><strong>Stale element refs</strong> happen after DOM mutation. Re-find; don’t cache element references across page transitions.</li>
  <li><strong>XPath 1.0 only.</strong> <code class="language-plaintext highlighter-rouge">for $i in …</code>, regex, <code class="language-plaintext highlighter-rouge">lower-case</code> with Unicode normalization — all 2.0+ and silently fail.</li>
</ol>

<h3 id="playwright">Playwright</h3>

<ol>
  <li><strong>Strict mode</strong> is on by default — <code class="language-plaintext highlighter-rouge">locator()</code> returning &gt;1 match throws. Use <code class="language-plaintext highlighter-rouge">.first()</code> / <code class="language-plaintext highlighter-rouge">.nth(i)</code> deliberately.</li>
  <li><strong>Locators are lazy.</strong> They re-resolve on every action. That’s a feature, not a bug. Don’t call <code class="language-plaintext highlighter-rouge">.elementHandle()</code> unless you need the snapshot.</li>
  <li><strong>Auto-retries</strong> are unlimited by default within <code class="language-plaintext highlighter-rouge">expect()</code>. Use <code class="language-plaintext highlighter-rouge">expect(locator).toBeVisible()</code> rather than <code class="language-plaintext highlighter-rouge">.waitFor()</code> + <code class="language-plaintext highlighter-rouge">.isVisible()</code>.</li>
  <li><strong>XPath auto-pierces shadow DOM</strong> via <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> chains. Pure XPath alone does not — use <code class="language-plaintext highlighter-rouge">page.locator('css=host-element &gt;&gt;&gt; inner-shadow-html')</code>.</li>
  <li><strong>Prefer semantic over xpath</strong> when the role is unambiguous: <code class="language-plaintext highlighter-rouge">getByRole('button', { name: 'Pay' })</code> beats <code class="language-plaintext highlighter-rouge">//button[normalize-space()='Pay']</code>.</li>
</ol>

<h3 id="cypress-1">Cypress</h3>

<ol>
  <li><strong>XPath needs the plugin</strong> (<code class="language-plaintext highlighter-rouge">cypress-xpath</code>). Out of the box, Cypress is CSS-first.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">.contains()</code></strong> is the Cypress-native way to do <code class="language-plaintext highlighter-rouge">"contains text"</code> — easier than XPath substring for visible-text filters.</li>
  <li><strong>Auto-retry</strong> is built in. <code class="language-plaintext highlighter-rouge">cy.get(locator).should('be.visible')</code> polls for you.</li>
  <li><strong>Element isolation:</strong> tests run inside one giant iframe; explicit shadow-DOM traversal rarely needed but XPath-on-shadow still won’t pierce.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cy.xpath()</code> returns a wrapper</strong>, not a DOM element. Chain <code class="language-plaintext highlighter-rouge">.click()</code> / <code class="language-plaintext highlighter-rouge">.type()</code> directly.</li>
</ol>

<h3 id="cross-framework">Cross-framework</h3>

<table>
  <thead>
    <tr>
      <th>Gotcha</th>
      <th>Selenium</th>
      <th>Playwright</th>
      <th>Cypress</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Default XPath engine</td>
      <td>1.0</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Shadow DOM piercing</td>
      <td>❌</td>
      <td>✅ (<code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code>)</td>
      <td>partial</td>
    </tr>
    <tr>
      <td>Iframe switching</td>
      <td>manual <code class="language-plaintext highlighter-rouge">switchTo().frame</code></td>
      <td>auto via locator chain</td>
      <td>manual</td>
    </tr>
    <tr>
      <td>Default strictness</td>
      <td>permissive (1 match OK)</td>
      <td>strict</td>
      <td>permissive</td>
    </tr>
    <tr>
      <td>Best locator style</td>
      <td>explicit <code class="language-plaintext highlighter-rouge">By.xpath()</code></td>
      <td><code class="language-plaintext highlighter-rouge">getByRole</code> / <code class="language-plaintext highlighter-rouge">getByTestId</code></td>
      <td><code class="language-plaintext highlighter-rouge">cy.get</code> + <code class="language-plaintext highlighter-rouge">.contains</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="9-when-not-to-use-xpath">9. When NOT to use XPath</h2>

<p>Some signals that you should reach for a different tool:</p>

<ul>
  <li><strong>You’re matching visible text only.</strong> Use Playwright’s <code class="language-plaintext highlighter-rouge">getByText('Sign in')</code> or Cypress’s <code class="language-plaintext highlighter-rouge">cy.contains('Sign in')</code> — both auto-retry.</li>
  <li><strong>You need a screenshot/visual assertion.</strong> XPath finds nodes, not pixels. Reach for image-diff tools (Playwright’s <code class="language-plaintext highlighter-rouge">expect(page).toHaveScreenshot()</code>).</li>
  <li><strong>You’re chaining into shadow DOM.</strong> Use Playwright’s <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> selector syntax — it’s purpose-built.</li>
  <li><strong>You need cross-origin iframe traversal.</strong> XPath stops at iframe boundaries regardless of tool. Switch contexts, or use CDP/BiDi for snapshot diff.</li>
  <li><strong>You’re navigating the accessibility tree.</strong> That’s ARIA’s job — use semantic locators instead.</li>
  <li><strong>The locator depends on element coordinates.</strong> XPath can’t. Use <code class="language-plaintext highlighter-rouge">Relative Locator</code> (Selenium 4: <code class="language-plaintext highlighter-rouge">above/below/near</code>) or <code class="language-plaintext highlighter-rouge">locator.boundingBox()</code> (Playwright).</li>
</ul>

<hr />

<h2 id="10-sdet-playbook-pom-waits-ci-observability">10. SDET playbook — POM, waits, CI, observability</h2>

<p>This section is for SDETs shipping production test suites in <strong>Selenium, Playwright, or Cypress</strong>. The XPaths above are correct; this section makes them <em>survive</em>.</p>

<h3 id="101-where-xpath-lives-in-your-pom">10.1 Where XPath lives in your POM</h3>

<p>The locator is a <strong>property</strong>, not a method. Locators as <code class="language-plaintext highlighter-rouge">By</code>/<code class="language-plaintext highlighter-rouge">Locator</code> constants on the page object — never inline strings in test specs.</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Lives in</th>
      <th>Style</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Locator strings</td>
      <td><code class="language-plaintext highlighter-rouge">BasePage</code> / <code class="language-plaintext highlighter-rouge">ComponentPage</code></td>
      <td>Const <code class="language-plaintext highlighter-rouge">By</code> / <code class="language-plaintext highlighter-rouge">Locator</code> properties</td>
    </tr>
    <tr>
      <td>Wait wrappers</td>
      <td><code class="language-plaintext highlighter-rouge">BasePage</code> helpers</td>
      <td><code class="language-plaintext highlighter-rouge">waitForVisible(by)</code>, <code class="language-plaintext highlighter-rouge">waitForEnabled(locator)</code></td>
    </tr>
    <tr>
      <td>Flow actions</td>
      <td>Component / Page Object methods</td>
      <td><code class="language-plaintext highlighter-rouge">loginAs(user, pass)</code>, <code class="language-plaintext highlighter-rouge">submitCheckout()</code></td>
    </tr>
    <tr>
      <td>Test assertions</td>
      <td>Spec file</td>
      <td>Calls page-object methods, only ever in <code class="language-plaintext highlighter-rouge">.should(...)</code></td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright TypeScript</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nx">CheckoutPage</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">form</span>        <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//form[@id='checkout']</span><span class="dl">"</span><span class="p">);</span>
  <span class="k">readonly</span> <span class="nx">payButton</span>   <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span>
    <span class="dl">"</span><span class="s2">//button[normalize-space()='Pay' and not(@disabled)]</span><span class="dl">"</span>
  <span class="p">);</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="k">private</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{}</span>

  <span class="k">async</span> <span class="nx">pay</span><span class="p">()</span> <span class="p">{</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">payButton</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium Java</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CheckoutPage</span> <span class="o">{</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
  <span class="kd">public</span> <span class="nf">CheckoutPage</span><span class="o">(</span><span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">)</span> <span class="o">{</span> <span class="k">this</span><span class="o">.</span><span class="na">driver</span> <span class="o">=</span> <span class="n">driver</span><span class="o">;</span> <span class="o">}</span>

  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">payBtn</span> <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span>
      <span class="s">"//form[@id='checkout']//button[normalize-space()='Pay' and not(@disabled)]"</span>
  <span class="o">);</span>

  <span class="kd">public</span> <span class="nc">CheckoutPage</span> <span class="nf">pay</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">new</span> <span class="nf">WebDriverWait</span><span class="o">(</span><span class="n">driver</span><span class="o">,</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">10</span><span class="o">))</span>
        <span class="o">.</span><span class="na">until</span><span class="o">(</span><span class="nc">ExpectedConditions</span><span class="o">.</span><span class="na">elementToBeClickable</span><span class="o">(</span><span class="n">payBtn</span><span class="o">))</span>
        <span class="o">.</span><span class="na">click</span><span class="o">();</span>
    <span class="k">return</span> <span class="k">this</span><span class="o">;</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Cypress</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">checkout</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">payBtn</span><span class="p">:</span> <span class="dl">"</span><span class="s2">//form[@id='checkout']//button[normalize-space()='Pay' and not(@disabled)]</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">pay</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="nx">checkout</span><span class="p">.</span><span class="nx">payBtn</span><span class="p">).</span><span class="nx">should</span><span class="p">(</span><span class="dl">'</span><span class="s1">be.enabled</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="102-wait-strategies-per-framework">10.2 Wait strategies per framework</h3>

<p>A correct XPath still times out if the framework’s wait contract is wrong.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Selenium (Java/Python/C#)  → wrap in WebDriverWait, return element when state met
Playwright (TS/JS)         → auto-wait up to 30s; use expect(locator).toBeVisible()
Cypress                    → auto-retry on cy.get/chained assertions for 4s default
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Default</th>
      <th>Wait helper</th>
      <th>Anti-pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Selenium 4</td>
      <td>None — synchronous query</td>
      <td><code class="language-plaintext highlighter-rouge">WebDriverWait(driver, 10).until(ExpectedConditions.elementToBeClickable(by))</code></td>
      <td><code class="language-plaintext highlighter-rouge">Thread.sleep(2000)</code></td>
    </tr>
    <tr>
      <td>Playwright</td>
      <td>Auto-wait 30s on every action</td>
      <td><code class="language-plaintext highlighter-rouge">await locator.click()</code> · <code class="language-plaintext highlighter-rouge">await expect(locator).toBeVisible()</code></td>
      <td>Redundant <code class="language-plaintext highlighter-rouge">.waitFor()</code> + <code class="language-plaintext highlighter-rouge">.click()</code> (.click() already auto-waits)</td>
    </tr>
    <tr>
      <td>Cypress</td>
      <td><code class="language-plaintext highlighter-rouge">cy.get</code> retries 4s on retry-ability</td>
      <td><code class="language-plaintext highlighter-rouge">cy.xpath(...).should('be.visible').click()</code></td>
      <td><code class="language-plaintext highlighter-rouge">.then(...)</code> swallows auto-retry</td>
    </tr>
  </tbody>
</table>

<h3 id="103-headless--ci-failure-modes">10.3 Headless / CI failure modes</h3>

<p>Three patterns that always bite in CI but pass on a laptop:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Always</span><span class="w"> </span><span class="nt">pin</span><span class="w"> </span><span class="nt">state</span><span class="o">,</span><span class="w"> </span><span class="nt">never</span><span class="w"> </span><span class="nt">animation</span><span class="w"> </span><span class="nt">timing</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'status'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Loaded'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="na">@data-testid</span><span class="o">=</span><span class="s">'submit'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span><span class="o">//</span><span class="nt">img</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@alt</span><span class="o">,</span><span class="s">'avatar'</span><span class="p">)</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="nf">starts-with</span><span class="p">(</span><span class="na">@src</span><span class="o">,</span><span class="s">'data:'</span><span class="p">))]</span><span class="w">
</span></code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Symptom in CI</th>
      <th>Root cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Locator times out only on CI runner</td>
      <td>Default viewport too small</td>
      <td><code class="language-plaintext highlighter-rouge">await page.setViewportSize({ width: 1280, height: 720 })</code> (Playwright)</td>
    </tr>
    <tr>
      <td>Screenshot shows element “missing” but DOM has it</td>
      <td>Animation timing under load</td>
      <td>Pin a state predicate (<code class="language-plaintext highlighter-rouge">@data-state='ready'</code>); don’t wait on <code class="language-plaintext highlighter-rouge">display:none → visible</code></td>
    </tr>
    <tr>
      <td>Visual diff fails in headless but works headed</td>
      <td>GPU compositing differs</td>
      <td>Use Playwright’s <code class="language-plaintext highlighter-rouge">await locator.waitFor({ state: 'visible' })</code> <strong>before</strong> screenshot</td>
    </tr>
  </tbody>
</table>

<h3 id="104-observability-hooks--capture-on-failure">10.4 Observability hooks — capture on failure</h3>

<p>When a CI test fails, three artifacts tell you <em>why</em>. Wire them in from day one.</p>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Screenshot</th>
      <th>HTML snapshot</th>
      <th>Failed XPath logged</th>
      <th>Trace/Video</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Playwright</strong></td>
      <td><code class="language-plaintext highlighter-rouge">screenshot: 'only-on-failure'</code> in config</td>
      <td><code class="language-plaintext highlighter-rouge">trace: 'retain-on-failure'</code></td>
      <td>Add to <code class="language-plaintext highlighter-rouge">expect()</code> failure message in custom matcher</td>
      <td><code class="language-plaintext highlighter-rouge">video: 'retain-on-failure'</code></td>
    </tr>
    <tr>
      <td><strong>Selenium</strong></td>
      <td><code class="language-plaintext highlighter-rouge">((TakesScreenshot) driver).getScreenshotAs(File)</code> in <code class="language-plaintext highlighter-rouge">@AfterMethod</code></td>
      <td><code class="language-plaintext highlighter-rouge">driver.getPageSource()</code></td>
      <td>Log <code class="language-plaintext highlighter-rouge">by.toString()</code> before failure</td>
      <td>CI runner captures: video, console</td>
    </tr>
    <tr>
      <td><strong>Cypress</strong></td>
      <td>Built-in (<code class="language-plaintext highlighter-rouge">cypress-on-fix</code> plugin)</td>
      <td>Built-in</td>
      <td>Extend Cypress <code class="language-plaintext highlighter-rouge">cypress-xpath</code> to expose failed path</td>
      <td>Built-in screencast for failures</td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — config already covers this, but make the XPath visible</span>
<span class="c1">// playwright.config.ts</span>
<span class="k">export</span> <span class="k">default</span> <span class="p">{</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">trace</span><span class="p">:</span> <span class="dl">'</span><span class="s1">retain-on-failure</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">screenshot</span><span class="p">:</span> <span class="dl">'</span><span class="s1">only-on-failure</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">video</span><span class="p">:</span> <span class="dl">'</span><span class="s1">retain-on-failure</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">};</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium — listener that always logs the failed By</span>
<span class="nd">@AfterMethod</span><span class="o">(</span><span class="n">alwaysRun</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">onFailure</span><span class="o">(</span><span class="nc">ITestResult</span> <span class="n">result</span><span class="o">)</span> <span class="o">{</span>
  <span class="k">if</span> <span class="o">(</span><span class="n">result</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">ITestResult</span><span class="o">.</span><span class="na">FAILURE</span><span class="o">)</span> <span class="k">return</span><span class="o">;</span>
  <span class="nc">WebDriver</span> <span class="n">d</span> <span class="o">=</span> <span class="nc">DriverFactory</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
  <span class="c1">// 1. Screenshot</span>
  <span class="nc">File</span> <span class="n">shot</span> <span class="o">=</span> <span class="o">((</span><span class="nc">TakesScreenshot</span><span class="o">)</span> <span class="n">d</span><span class="o">).</span><span class="na">getScreenshotAs</span><span class="o">(</span><span class="nc">OutputType</span><span class="o">.</span><span class="na">FILE</span><span class="o">);</span>
  <span class="nc">Reporter</span><span class="o">.</span><span class="na">log</span><span class="o">(</span><span class="s">"Screenshot: "</span> <span class="o">+</span> <span class="n">shot</span><span class="o">.</span><span class="na">getAbsolutePath</span><span class="o">(),</span> <span class="kc">true</span><span class="o">);</span>
  <span class="c1">// 2. HTML</span>
  <span class="nc">String</span> <span class="n">html</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="na">getPageSource</span><span class="o">();</span>
  <span class="nc">Reporter</span><span class="o">.</span><span class="na">log</span><span class="o">(</span><span class="s">"HTML at failure (first 4KB):\n"</span> <span class="o">+</span> <span class="n">html</span><span class="o">.</span><span class="na">substring</span><span class="o">(</span><span class="mi">0</span><span class="o">,</span> <span class="mi">4096</span><span class="o">),</span> <span class="kc">true</span><span class="o">);</span>
  <span class="c1">// 3. Last-tried locator (set in catch block of helper)</span>
  <span class="nc">Reporter</span><span class="o">.</span><span class="na">log</span><span class="o">(</span><span class="s">"Last XPath tried: "</span> <span class="o">+</span> <span class="nc">LastLocatorHolder</span><span class="o">.</span><span class="na">get</span><span class="o">(),</span> <span class="kc">true</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Cypress — fail-fast that prepends the failing XPath to the error</span>
<span class="c1">// (idiomatic; works with cypress-xpath v2.1+ and Cypress 12+)</span>
<span class="nx">Cypress</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">fail</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">tried</span> <span class="o">=</span> <span class="nx">Cypress</span><span class="p">.</span><span class="nx">env</span><span class="p">(</span><span class="dl">'</span><span class="s1">lastXpath</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">tried</span><span class="p">)</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span> <span class="o">=</span> <span class="s2">`Failed XPath: </span><span class="p">${</span><span class="nx">tried</span><span class="p">}</span><span class="s2">\n</span><span class="p">${</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
  <span class="k">throw</span> <span class="nx">err</span><span class="p">;</span>
<span class="p">});</span>

<span class="c1">// Helper: set the env var before any assertion</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">xpathClick</span> <span class="o">=</span> <span class="p">(</span><span class="nx">expression</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">Cypress</span><span class="p">.</span><span class="nx">env</span><span class="p">(</span><span class="dl">'</span><span class="s1">lastXpath</span><span class="dl">'</span><span class="p">,</span> <span class="nx">expression</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="nx">expression</span><span class="p">).</span><span class="nx">should</span><span class="p">(</span><span class="dl">'</span><span class="s1">be.visible</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="p">};</span>

<span class="c1">// Usage:</span>
<span class="c1">// xpathClick("//button[normalize-space()='Pay' and not(@disabled)]")</span>
</code></pre></div></div>

<blockquote>
  <p><strong>Why not <code class="language-plaintext highlighter-rouge">Commands.overwrite('xpath', …)</code>?</strong> Re-throwing inside a <code class="language-plaintext highlighter-rouge">.then(null, errFn)</code> swallows the rejection inside Cypress’s <code class="language-plaintext highlighter-rouge">chainer</code> and the override never actually surfaces in the reporter. The <code class="language-plaintext highlighter-rouge">Cypress.on('fail')</code> global hook is the reliable place to rewrite the failing message.</p>
</blockquote>

<h3 id="105-the-sdet--frontend-data-testid-contract">10.5 The SDET ↔ Frontend <code class="language-plaintext highlighter-rouge">data-testid</code> contract</h3>

<p>You own the <strong>naming convention</strong>, not individual test IDs. Negotiate this with frontend in one paragraph:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Convention: data-testid = "&lt;page&gt;-&lt;component&gt;-&lt;intent&gt;"

Examples:    checkout-pay-button · cart-remove-button · settings-save-button
Forbidden:   numbers (button-1), intent-free (right-button), leak class (btn-primary)
</code></pre></div></div>

<p>Then in your POM, replace 80% of the XPaths in this cheatsheet with <code class="language-plaintext highlighter-rouge">getByTestId(...)</code>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">readonly</span> <span class="nx">payButton</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">"</span><span class="s2">checkout-pay-button</span><span class="dl">"</span><span class="p">);</span>
<span class="k">readonly</span> <span class="nx">removeBtn</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">"</span><span class="s2">cart-remove-button</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>XPath becomes the <strong>fallback</strong> for the remaining 20% — third-party widgets, embedded <code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code>s, legacy modals, SVG nodes, Canvas (no DOM at all). Those escape hatches are precisely what §§3–7 teach.</p>

<h3 id="106-browser-matrix-sanity">10.6 Browser matrix sanity</h3>

<p>When the same XPath runs across Chrome/Firefox/Safari/Edge, three things break first:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="mf">1.</span><span class="w"> </span><span class="k">text</span><span class="p">()</span><span class="w"> </span><span class="ow">is</span><span class="w"> </span><span class="nt">engine-sensitive</span><span class="w"> </span><span class="err">—</span><span class="w"> </span><span class="nt">always</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">()</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">h1</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Welcome'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="mf">2.</span><span class="w"> </span><span class="nt">SVG</span><span class="w"> </span><span class="ow">is</span><span class="w"> </span><span class="nt">namespace-prefixed</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="k">some</span><span class="w"> </span><span class="nt">browsers</span><span class="o">,</span><span class="w"> </span><span class="nt">not</span><span class="w"> </span><span class="nt">others</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'svg'</span><span class="p">]</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'path'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@fill</span><span class="o">=</span><span class="s">'#0ea5c7'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="mf">3.</span><span class="w"> </span><span class="nt">case-insensitivity</span><span class="w"> </span><span class="err">—</span><span class="w"> </span><span class="nt">use</span><span class="w"> </span><span class="nf">translate</span><span class="p">()</span><span class="o">,</span><span class="w"> </span><span class="nt">not</span><span class="w"> </span><span class="nf">lower-case</span><span class="p">()</span><span class="w"> </span><span class="p">(</span><span class="nt">XPath</span><span class="w"> </span><span class="mf">2.0</span><span class="o">+</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">a</span><span class="p">[</span><span class="nf">translate</span><span class="p">(</span><span class="na">@href</span><span class="o">,</span><span class="w">
            </span><span class="s">'ABCDEFGHIJKLMNOPQRSTUVWXYZ'</span><span class="o">,</span><span class="w">
            </span><span class="s">'abcdefghijklmnopqrstuvwxyz'</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">'/help'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="107-sdet-cleanup-checklist-before-commit">10.7 SDET cleanup checklist before commit</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>☐  No inline By.xpath("//...") literal in spec files (must live in POM)
☐  Each XPath is anchored on stable feature (role/id/data-testid/text), not on index
☐  Effective wait contract matches framework (WebDriverWait / auto-wait / should())
☐  Failure hook captures {screenshot, HTML, last XPath} in the report
☐  No lower-case() / upper-case() / FLWOR / regex (XPath 2.0+ silently fails)
☐  No substring-class matching (contains(@class,'btn')) — use space-padded concat
☐  data-testid request ticket exists for new selector buckets the team hasn't covered
</code></pre></div></div>

<hr />

<h2 id="11-advanced-complex-patterns-svg-shadow-dom-modern-css">11. Advanced &amp; complex patterns — SVG, shadow DOM, modern CSS</h2>

<p>Use this section when the basic patterns from §§1–10 don’t reach the element. The four “complex” categories here are <strong>SVG namespace</strong>, <strong>computed indices without <code class="language-plaintext highlighter-rouge">li[3]</code></strong>, <strong>iframe + shadow DOM piercing</strong>, and <strong>modern CSS Level 4 selectors</strong> (<code class="language-plaintext highlighter-rouge">:has()</code>, <code class="language-plaintext highlighter-rouge">:is()</code>, <code class="language-plaintext highlighter-rouge">:where()</code>, <code class="language-plaintext highlighter-rouge">:nth-child()</code> formulas, sibling combinators, case-insensitive attribute flags).</p>

<h3 id="111-complex-xpath--quick-reference-table">11.1 Complex XPath — quick reference table</h3>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>XPath</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SVG <code class="language-plaintext highlighter-rouge">&lt;path&gt;</code> (any depth)</td>
      <td><code class="language-plaintext highlighter-rouge">//*[local-name()='path']</code></td>
      <td><code class="language-plaintext highlighter-rouge">local-name()</code> strips namespace prefix</td>
    </tr>
    <tr>
      <td>SVG with attribute filter</td>
      <td><code class="language-plaintext highlighter-rouge">//*[local-name()='path' and @fill='#0ea5c7']</code></td>
      <td>Two predicates, AND-combined</td>
    </tr>
    <tr>
      <td>Computed nth (3rd <code class="language-plaintext highlighter-rouge">&lt;li&gt;</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[count(preceding-sibling::li) = 2]</code></td>
      <td>Survives inserts above</td>
    </tr>
    <tr>
      <td>Last N items</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[count(following-sibling::li) &lt; 3]</code></td>
      <td>count-following rule</td>
    </tr>
    <tr>
      <td>Skip first 5 of 10</td>
      <td><code class="language-plaintext highlighter-rouge">//li[position() &gt; 5 and position() &lt;= 10]</code></td>
      <td>Compound position predicate</td>
    </tr>
    <tr>
      <td>Row by header label</td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[th[normalize-space()='X']]/td[2]</code></td>
      <td>Multi-axis via header semantics</td>
    </tr>
    <tr>
      <td>Row by computed cells</td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[td[2][normalize-space()='Open']]/td[3]</code></td>
      <td>Predicate across two cell positions</td>
    </tr>
    <tr>
      <td>Every 2nd row (1-indexed odd)</td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[position() mod 2 = 1]</code></td>
      <td><code class="language-plaintext highlighter-rouge">mod 2 = 0</code> for even</td>
    </tr>
    <tr>
      <td>colspan-aware next cell</td>
      <td><code class="language-plaintext highlighter-rouge">//td[@colspan=3]/following-sibling::td[1]</code></td>
      <td>Tables with merged columns</td>
    </tr>
    <tr>
      <td>ARIA expanded tree item</td>
      <td><code class="language-plaintext highlighter-rouge">//*[@role='treeitem' and @aria-expanded='true']</code></td>
      <td>State predicate</td>
    </tr>
    <tr>
      <td>Visible tabpanel + editable input</td>
      <td><code class="language-plaintext highlighter-rouge">//*[@role='tabpanel' and @aria-hidden='false']//input[not(@readonly)]</code></td>
      <td>Multi-predicate nested</td>
    </tr>
    <tr>
      <td>Negate prefix (submit vs submit-draft)</td>
      <td><code class="language-plaintext highlighter-rouge">//button[starts-with(@id,'submit-') and not(starts-with(@id,'submit-draft-'))]</code></td>
      <td>Two starts-with()</td>
    </tr>
    <tr>
      <td>Compound OR (3 attr sources)</td>
      <td><code class="language-plaintext highlighter-rouge">//button[(@type='submit' or @type='button' or @role='button') and not(@disabled)]</code></td>
      <td>OR group + state filter</td>
    </tr>
    <tr>
      <td>Compound AND (case-insensitive + state)</td>
      <td><code class="language-plaintext highlighter-rouge">//a[translate(@href,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='/help' and not(@target='_blank')]</code></td>
      <td>Use <code class="language-plaintext highlighter-rouge">translate()</code> not <code class="language-plaintext highlighter-rouge">lower-case()</code></td>
    </tr>
    <tr>
      <td>Compound text concat with descendant</td>
      <td><code class="language-plaintext highlighter-rouge">//div[normalize-space() = concat('Status: ', normalize-space(.//span))]</code></td>
      <td><code class="language-plaintext highlighter-rouge">concat()</code> joins strings</td>
    </tr>
    <tr>
      <td>Reverse navigation + state</td>
      <td><code class="language-plaintext highlighter-rouge">//section[.//h2[normalize-space()='Billing']]//input[@name='card']</code></td>
      <td>Section contains matching h2</td>
    </tr>
  </tbody>
</table>

<h3 id="112-svg-namespace--portably">11.2 SVG namespace — portably</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">by</span><span class="w"> </span><span class="nt">stripped</span><span class="w"> </span><span class="nt">local</span><span class="w"> </span><span class="nf">name</span><span class="w"> </span><span class="p">(</span><span class="nt">preferred</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'svg'</span><span class="p">]</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'path'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">by</span><span class="w"> </span><span class="nt">namespace</span><span class="w"> </span><span class="nt">URI</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nt">local</span><span class="w"> </span><span class="nf">name</span><span class="w"> </span><span class="p">(</span><span class="nt">rare</span><span class="o">,</span><span class="w"> </span><span class="nt">advanced</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">namespace-uri</span><span class="p">()</span><span class="o">=</span><span class="s">'http://www.w3.org/2000/svg'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'rect'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Match</span><span class="w"> </span><span class="nt">by</span><span class="w"> </span><span class="nt">prefixed</span><span class="w"> </span><span class="nt">name</span><span class="w"> </span><span class="nt">when</span><span class="w"> </span><span class="nt">output</span><span class="w"> </span><span class="ow">is</span><span class="w"> </span><span class="nt">engine-consistent</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">name</span><span class="p">()</span><span class="o">=</span><span class="s">'svg:circle'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — SVG via CSS works in 2026+ for major browsers</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">svg circle[fill='#0ea5c7']</span><span class="dl">"</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="c1">// XPath fallback for cross-engine reliability</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//*[local-name()='svg']//*[local-name()='circle']</span><span class="dl">"</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="113-iframe--shadow-dom--what-works-where">11.3 iframe + shadow DOM — what works where</h3>

<table>
  <thead>
    <tr>
      <th>Boundary</th>
      <th>Selenium 4</th>
      <th>Playwright</th>
      <th>Cypress</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>iframe (same-origin)</td>
      <td><code class="language-plaintext highlighter-rouge">driver.switchTo().frame(name)</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.frameLocator(iframe).locator(...)</code></td>
      <td><code class="language-plaintext highlighter-rouge">cy.frameLoaded</code> + <code class="language-plaintext highlighter-rouge">cy.iframe</code></td>
    </tr>
    <tr>
      <td>iframe (cross-origin)</td>
      <td>Via BiDi CDP proxy; otherwise unreachable</td>
      <td><code class="language-plaintext highlighter-rouge">page.frame(url, ...)</code> via BiDi; otherwise proxy</td>
      <td><code class="language-plaintext highlighter-rouge">cy.origin(url, fn)</code> then XPath inside</td>
    </tr>
    <tr>
      <td>Shadow DOM (open root)</td>
      <td><code class="language-plaintext highlighter-rouge">shadowRoot.findElement(By.css("css"))</code> after <code class="language-plaintext highlighter-rouge">getShadowRoot()</code></td>
      <td><code class="language-plaintext highlighter-rouge">page.locator('host-css &gt;&gt;&gt; inner')</code> (auto-pierces)</td>
      <td>CSS pierces partially; XPath alone cannot</td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — iframe + selector across frames</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">frameLocator</span><span class="p">(</span><span class="dl">'</span><span class="s1">iframe[name="payment"]</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//input[@name='card']</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">'</span><span class="s1">4111111111111111</span><span class="dl">'</span><span class="p">);</span>

<span class="c1">// Playwright — shadow DOM via &gt;&gt;&gt; chaining (CSS host, XPath inside)</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">my-payment-form &gt;&gt;&gt; //button:has-text("Pay")</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="114-modern-css--has-is-where">11.4 Modern CSS — <code class="language-plaintext highlighter-rouge">:has()</code>, <code class="language-plaintext highlighter-rouge">:is()</code>, <code class="language-plaintext highlighter-rouge">:where()</code></h3>

<table>
  <thead>
    <tr>
      <th>Selector</th>
      <th>Meaning</th>
      <th>Available since</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:has(selector)</code></td>
      <td>Parent contains a child matching selector</td>
      <td>Chromium 105+, Firefox 121+, Safari 15.4+</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:is(h1, h2, h3)</code></td>
      <td>Matches any of; keeps specificity of the most-specific branch</td>
      <td>Chrome 88+, Firefox 78+, Safari 14+</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:where(h1, h2, h3)</code></td>
      <td>Matches any of; specificity 0 (overridable)</td>
      <td>Chrome 88+, Firefox 78+, Safari 14+</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:not(:disabled):not(:checked)</code></td>
      <td>Multi-condition negation</td>
      <td>Universal</td>
    </tr>
  </tbody>
</table>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* :has — parent selector */</span>
<span class="nc">.card</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.icon.danger</span><span class="o">)</span>             <span class="c">/* card with a danger-icon descendant */</span>
<span class="nt">li</span><span class="nd">:has</span><span class="o">(</span><span class="nt">a</span><span class="o">)</span> <span class="o">~</span> <span class="nt">li</span><span class="nd">:not</span><span class="o">(</span><span class="nd">:has</span><span class="o">(</span><span class="nt">a</span><span class="o">))</span>         <span class="c">/* sibling-of-a-having-a followed by a sibling-without-a */</span>
<span class="nt">form</span><span class="nd">:has</span><span class="o">(</span><span class="nt">input</span><span class="nd">:invalid</span><span class="o">)</span>             <span class="c">/* form containing any invalid input */</span>

<span class="c">/* :is — OR grouping with combined specificity */</span>
<span class="nc">.title</span><span class="nd">:is</span><span class="o">(</span><span class="nt">h1</span><span class="o">,</span> <span class="nt">h2</span><span class="o">,</span> <span class="nt">h3</span><span class="o">,</span> <span class="nt">h4</span><span class="o">)</span>           <span class="c">/* any of these headings with class title */</span>

<span class="c">/* :where — OR grouping with zero specificity */</span>
<span class="nc">.title</span><span class="nd">:where</span><span class="o">(</span><span class="nt">h1</span><span class="o">,</span> <span class="nt">h2</span><span class="o">,</span> <span class="nt">h3</span><span class="o">,</span> <span class="nt">h4</span><span class="o">)</span>        <span class="c">/* same semantic, easier for downstream override */</span>

<span class="c">/* Multi-source button OR with type + class + ARIA */</span>
<span class="nt">button</span><span class="nd">:is</span><span class="o">([</span><span class="nt">type</span><span class="o">=</span><span class="s2">'submit'</span><span class="o">],</span> <span class="nc">.btn-primary</span><span class="o">,</span> <span class="o">[</span><span class="nt">aria-label</span><span class="o">*=</span><span class="s2">'Pay'</span> <span class="nt">i</span><span class="o">])</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">.card:has(.icon.danger)</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">has</span><span class="p">:</span>     <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">a</span><span class="dl">'</span><span class="p">)</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">hasNot</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">label</span><span class="dl">'</span><span class="p">)</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium 4 — :has() works in cssSelector once the browser engine supports it</span>
<span class="c1">// requires: import org.openqa.selenium.By;</span>
<span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">".card:has(.icon.danger)"</span><span class="o">));</span>
</code></pre></div></div>

<h3 id="115-nth-child-and-nth-of-type-formulas">11.5 <code class="language-plaintext highlighter-rouge">:nth-child()</code> and <code class="language-plaintext highlighter-rouge">:nth-of-type()</code> formulas</h3>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Common nth-child formulas */</span>
<span class="nt">tr</span><span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="nt">n</span><span class="o">)</span>                       <span class="c">/* even rows (2nd, 4th, 6th...) */</span>
<span class="nt">tr</span><span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="nt">n</span><span class="o">+</span><span class="err">1</span><span class="o">)</span>                     <span class="c">/* odd rows (1st, 3rd, 5th...) */</span>
<span class="nt">li</span><span class="nd">:nth-child</span><span class="o">(</span><span class="nt">-n</span><span class="o">+</span><span class="err">5</span><span class="o">)</span>                     <span class="c">/* first 5 items */</span>
<span class="nt">td</span><span class="nd">:nth-last-child</span><span class="o">(</span><span class="nt">-n</span><span class="o">+</span><span class="err">2</span><span class="o">)</span>                <span class="c">/* last 2 cells */</span>
<span class="nd">:nth-child</span><span class="o">(</span><span class="err">3</span><span class="nt">n</span><span class="o">+</span><span class="err">1</span><span class="o">)</span>                       <span class="c">/* every 3rd, starting at position 1 */</span>
<span class="nt">tr</span><span class="nd">:only-child</span>                          <span class="c">/* row with no siblings */</span>

<span class="c">/* Type-aware */</span>
<span class="nt">td</span><span class="nd">:nth-of-type</span><span class="o">(</span><span class="err">4</span><span class="o">)</span>                      <span class="c">/* 4th td by element type */</span>
<span class="nt">h2</span><span class="nd">:nth-of-type</span><span class="o">(</span><span class="err">2</span><span class="o">)</span> <span class="o">~</span> <span class="nt">p</span><span class="nd">:nth-of-type</span><span class="o">(</span><span class="err">1</span><span class="o">)</span>   <span class="c">/* 2nd h2 then 1st p after it */</span>
<span class="nt">section</span> <span class="o">&gt;</span> <span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="o">)</span>                <span class="c">/* 2nd direct child of any section */</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — nth-child() inside locator() strings</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr:nth-child(2n+1) td:nth-child(3)</span><span class="dl">'</span><span class="p">).</span><span class="nx">allInnerTexts</span><span class="p">();</span>

<span class="c1">// Or, more idiomatic, via locator API</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr</span><span class="dl">'</span><span class="p">).</span><span class="nx">nth</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr:nth-child(2n)</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">hasText</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Total</span><span class="dl">'</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="116-case-insensitive-attribute-flags--state-pseudos">11.6 Case-insensitive attribute flags + state pseudos</h3>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Case-insensitive attribute matching (CSS Level 4 flag form) */</span>
<span class="nt">a</span><span class="o">[</span><span class="nt">href</span><span class="o">*=</span><span class="s1">"github"</span> <span class="nt">i</span><span class="o">]</span>                    <span class="c">/* matches GitHub / GITHUB / github */</span>
<span class="nt">input</span><span class="o">[</span><span class="nt">name</span><span class="o">*=</span><span class="s1">"email"</span> <span class="nt">i</span><span class="o">]</span>
<span class="o">[</span><span class="nt">type</span><span class="o">=</span><span class="s1">"checkbox"</span> <span class="nt">i</span><span class="o">]</span>                    <span class="c">/* exact match, insensitive */</span>
<span class="o">[</span><span class="nt">title</span><span class="o">*=</span><span class="s1">"Pay"</span> <span class="nt">i</span><span class="o">]</span>                       <span class="c">/* substring match, insensitive */</span>

<span class="c">/* State pseudos */</span>
<span class="nt">input</span><span class="nd">:placeholder-shown</span>                <span class="c">/* empty input currently showing placeholder */</span>
<span class="nt">form</span><span class="nd">:focus-within</span>                      <span class="c">/* form containing the focused field */</span>
<span class="nt">button</span><span class="nd">:not</span><span class="o">(</span><span class="nd">:disabled</span><span class="o">)</span><span class="nd">:hover</span>            <span class="c">/* interactive (flaky in CI animation frames) */</span>
<span class="nt">li</span><span class="nd">:empty</span>                               <span class="c">/* &lt;li&gt;&lt;/li&gt; with no children at all */</span>
<span class="nt">input</span><span class="nd">:placeholder-shown</span> <span class="o">~</span> <span class="nt">label</span>        <span class="c">/* label adjacent to a currently-empty input */</span>
<span class="nd">:is</span><span class="o">(</span><span class="nd">:disabled</span><span class="o">,</span> <span class="o">[</span><span class="nt">aria-disabled</span><span class="o">=</span><span class="s2">'true'</span><span class="o">])</span> <span class="c">/* group OR negation */</span>
</code></pre></div></div>

<h3 id="117-sibling-combinators--">11.7 Sibling combinators (<code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">~</code>)</h3>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Adjacent sibling — directly following element */</span>
<span class="nt">button</span> <span class="o">+</span> <span class="nt">button</span>                          <span class="c">/* button immediately after another button */</span>

<span class="c">/* General sibling — any element after, sharing parent */</span>
<span class="nt">input</span><span class="nd">:invalid</span> <span class="o">~</span> <span class="nc">.error-icon</span>              <span class="c">/* any error-icon after an invalid input */</span>
<span class="nt">form</span> <span class="nt">label</span><span class="nd">:first-of-type</span> <span class="o">~</span> <span class="nt">input</span><span class="nd">:not</span><span class="o">([</span><span class="nt">type</span><span class="o">=</span><span class="s2">'hidden'</span><span class="o">])</span><span class="nd">:first-of-type</span>
                                        <span class="c">/* first visible input after first label */</span>
<span class="nt">header</span> <span class="o">&gt;</span> <span class="nt">nav</span> <span class="o">&gt;</span> <span class="nt">ul</span> <span class="o">&gt;</span> <span class="nt">li</span> <span class="o">+</span> <span class="nt">li</span>              <span class="c">/* li directly after another li in nav */</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — sibling combinators inside locator() + filter({ has })</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">button + button</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">input:invalid ~ .error-icon</span><span class="dl">'</span><span class="p">).</span><span class="nx">first</span><span class="p">().</span><span class="nx">textContent</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="118-xpath-vs-css-vs-playwright-engine--when-each-wins">11.8 XPath vs CSS vs Playwright engine — when each wins</h3>

<table>
  <thead>
    <tr>
      <th>Need</th>
      <th>XPath</th>
      <th>CSS</th>
      <th>Playwright engine (<code class="language-plaintext highlighter-rouge">role=</code>, <code class="language-plaintext highlighter-rouge">text=</code>, <code class="language-plaintext highlighter-rouge">near=</code>)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Visible-text match</td>
      <td>✅ <code class="language-plaintext highlighter-rouge">text()</code>, <code class="language-plaintext highlighter-rouge">normalize-space()</code></td>
      <td>⚠️ via <code class="language-plaintext highlighter-rouge">:has-text</code>/Playwright only</td>
      <td>✅ best (auto-retry, intent-clear)</td>
    </tr>
    <tr>
      <td>Forward-only traversal</td>
      <td>✅</td>
      <td>✅ fastest</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Backward / ancestor traversal</td>
      <td>✅ only</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Parent selector (X:has Y)</td>
      <td>✅ via axis</td>
      <td>✅ <code class="language-plaintext highlighter-rouge">:has()</code> in 2026+</td>
      <td>⚠️ <code class="language-plaintext highlighter-rouge">filter({ has })</code></td>
    </tr>
    <tr>
      <td>SVG / xmlns elements</td>
      <td>✅ via <code class="language-plaintext highlighter-rouge">local-name()</code></td>
      <td>⚠️ browser-version dependent</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Compound attribute predicates</td>
      <td>✅ best</td>
      <td>⚠️ attribute selectors + pseudos</td>
      <td>⚠️ partial</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:nth-child()</code> / sibling combinators</td>
      <td>✅ via <code class="language-plaintext highlighter-rouge">position()</code>, <code class="language-plaintext highlighter-rouge">count()</code></td>
      <td>✅ native <code class="language-plaintext highlighter-rouge">:nth-child()</code>, <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">~</code></td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Shadow DOM piercing</td>
      <td>❌ (spec stops)</td>
      <td>❌ without <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code></td>
      <td>✅ <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> chain</td>
    </tr>
    <tr>
      <td>iframe boundary</td>
      <td>❌</td>
      <td>❌</td>
      <td>✅ via <code class="language-plaintext highlighter-rouge">frameLocator(url)</code></td>
    </tr>
    <tr>
      <td>Speed (browser engine)</td>
      <td>⚠️ slower (~25% vs CSS)</td>
      <td>✅ fastest</td>
      <td>❌ slowest (semantic resolution)</td>
    </tr>
    <tr>
      <td>Cross-runner portability (Selenium + Playwright + Cypress)</td>
      <td>✅ XPath 1.0 portable everywhere</td>
      <td>⚠️ L4 selectors partially portable</td>
      <td>❌ Playwright-only</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Rule of thumb:</strong> CSS for speed and forward-only structural patterns; XPath for text matching and reverse/ancestor; Playwright engine selectors when ARIA role + accessible name expresses intent.</p>
</blockquote>

<h3 id="119-common-complex-target-patterns">11.9 Common complex-target patterns</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Date</span><span class="w"> </span><span class="nt">picker</span><span class="o">:</span><span class="w"> </span><span class="s">'next-month'</span><span class="w"> </span><span class="nt">button</span><span class="w"> </span><span class="nt">regardless</span><span class="w"> </span><span class="nt">of</span><span class="w"> </span><span class="nt">month</span><span class="w"> </span><span class="nt">label</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="na">@aria-label</span><span class="o">=</span><span class="s">'Next month'</span><span class="w"> </span><span class="ow">or</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'›'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Aria-disabled</span><span class="w"> </span><span class="nf">checkbox</span><span class="w"> </span><span class="p">(</span><span class="nt">selector</span><span class="w"> </span><span class="nt">target</span><span class="w"> </span><span class="nt">still</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nt">the</span><span class="w"> </span><span class="nt">DOM</span><span class="w"> </span><span class="nt">but</span><span class="w"> </span><span class="nt">inert</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'checkbox'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-disabled</span><span class="o">=</span><span class="s">'true'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Active</span><span class="w"> </span><span class="nt">step</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">wizard</span><span class="s">'s progress bar --&gt;
//li[@role='</span><span class="nt">tab</span><span class="s">' and @aria-selected='</span><span class="nt">true</span><span class="s">']/following-sibling::li

&lt;!-- Cell inside a row whose second cell holds partial text --&gt;
//table//tr[contains(td[2],'</span><span class="nt">Open</span><span class="s">')]/td[3]

&lt;!-- The form whose submit button is currently disabled --&gt;
//form[.//button[@type='</span><span class="nt">submit</span><span class="s">' and @disabled]]

&lt;!-- Last row whose first cell is not empty, in a striped table --&gt;
//table//tr[td[1][normalize-space()!='</span><span class="err">'</span><span class="p">]][</span><span class="nf">position</span><span class="p">()</span><span class="o">=</span><span class="nf">last</span><span class="p">()]</span><span class="w">
</span></code></pre></div></div>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* The currently focused form row (compound state) */</span>
<span class="nc">.form-row</span><span class="nd">:focus-within</span>

<span class="c">/* A button whose tooltip is currently visible */</span>
<span class="nt">button</span><span class="o">[</span><span class="nt">aria-describedby</span><span class="o">]</span><span class="nd">:hover</span>

<span class="c">/* Every other cell starting from column 2 */</span>
<span class="nt">td</span><span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="nt">n</span><span class="o">+</span><span class="err">1</span><span class="o">)</span><span class="nd">:nth-child</span><span class="o">(</span><span class="nt">n</span><span class="o">+</span><span class="err">2</span><span class="o">)</span>

<span class="c">/* A list with at least one selected child */</span>
<span class="nt">ul</span><span class="nd">:has</span><span class="o">(</span><span class="nt">li</span><span class="o">[</span><span class="nt">aria-selected</span><span class="o">=</span><span class="s2">'true'</span><span class="o">])</span>

<span class="c">/* Inputs that have ever been interacted with (focus or blur) */</span>
<span class="nt">input</span><span class="nd">:focus</span><span class="o">,</span> <span class="nt">input</span><span class="nd">:focus-within</span>

<span class="c">/* Inputs with a sibling error indicator */</span>
<span class="nt">input</span><span class="nd">:invalid</span> <span class="o">~</span> <span class="nc">.error-icon</span><span class="nd">:visible</span>
</code></pre></div></div>

<hr />
<p>```</p>

<hr />

<h2 id="anchor-index-for-the-rest-of-the-article">Anchor index (for the rest of the article)</h2>

<ul>
  <li><a href="#1-locator-priority-pyramid">Locator priority pyramid</a> — when to use what</li>
  <li><a href="#2-five-syntax-patterns-you-must-memorize">Five syntax patterns</a> — base grammar</li>
  <li><a href="#3-the-13-axes">The 13 axes</a> — full directions library</li>
  <li><a href="#4-10-functions-youll-actually-use">10 functions</a> — day-to-day toolkit</li>
  <li><a href="#5-predicate-recipes">Predicate recipes</a> — copy-paste catalog</li>
  <li><a href="#6-common-target-patterns">Common-target patterns</a> — forms, tables, modals, lists</li>
  <li><a href="#7-multi-language-code-samples">Multi-language samples</a> — Java · Python · TS/JS · C# · Cypress</li>
  <li><a href="#8-gotchas-per-framework">Gotchas per framework</a> — Selenium · Playwright · Cypress</li>
  <li><a href="#9-when-not-to-use-xpath">When NOT to use XPath</a> — better alternatives</li>
  <li><a href="#10-sdet-playbook-pom-waits-ci-observability">SDET playbook</a> — POM, waits, CI, observability</li>
  <li><a href="#11-advanced-complex-patterns-svg-shadow-dom-modern-css">Advanced &amp; complex patterns</a> — SVG, shadow DOM, modern CSS</li>
</ul>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://devhints.io/xpath">XPath — devhints.io</a> — the inspiration for this page</li>
  <li><a href="https://playwright.dev/docs/locators">Playwright locators</a> — when to graduate from XPath</li>
  <li><a href="https://www.selenium.dev/documentation/webdriver/elements/locators/">Selenium By API reference</a> — official locator docs</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/XPath/Comparison_with_CSS_selectors">MDN — Comparison of CSS Selectors with XPath</a> — XPath vs CSS comparison</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors">MDN — CSS Selectors Level 4</a> — <code class="language-plaintext highlighter-rouge">:has()</code>, <code class="language-plaintext highlighter-rouge">:is()</code>, <code class="language-plaintext highlighter-rouge">:where()</code>, <code class="language-plaintext highlighter-rouge">:nth-child()</code> formulas</li>
</ol>

<h2 id="cross-links-from-this-blog">Cross-links from this blog</h2>

<ul>
  <li><a href="/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/">XPath for Test Automation: From "I Hate This" to "I Write It in My Sleep" (Jul 2026)</a> — the story-mode article that teaches the mental model behind this cheatsheet</li>
  <li><a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium in 2026: Beginner’s Guide (Jul 2026)</a> — Selenium Manager, BiDi, MCP server</li>
  <li><a href="/techtalkwith-veeresh/automation/tools/selenium-page-locator-strategies/">Selenium Page Locator Strategies (May 2020)</a> — the foundational ID/class/CSS/XPath guide</li>
  <li><a href="/techtalkwith-veeresh/PLAYWRIGHT_GUIDE.md">The Playwright Guide</a> — locators and assertions in TypeScript, consolidated timeouts deep dive</li>
  <li><a href="/techtalkwith-veeresh/automation/tools/playwright-vs-selenium-2026/">Playwright vs Selenium 2026 (Jun 2026)</a> — when to use Playwright vs Selenium</li>
  <li><a href="/techtalkwith-veeresh/devops/automation/ci-cd-pipelines-for-test-automation/">CI/CD Pipelines for Test Automation (Jun 2026)</a> — sharding, parallel runs, observability in CI</li>
  <li><a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">Selenium BiDi vs Playwright CDP (Jul 2026)</a> — when BiDi/CDP replaces WebDriver</li>
</ul>

<p><em>See also:</em> <a href="/techtalkwith-veeresh/best-practices/frameworks/ai-driven-test-strategy/">AI-Driven Test Strategy (Jun 2026)</a> — when AI finds your locators by looking at the page. · <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix/">XPath ↔ CSS Translation Appendix (Jul 2026)</a> — the compact one-page translation map and Selenium→Playwright migration playbook this cheatsheet complements.</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="tools" /><category term="reference" /><category term="xpath" /><category term="locators" /><category term="cheatsheet" /><category term="reference" /><category term="selenium" /><category term="playwright" /><category term="cypress" /><category term="css-selectors" /><category term="sdet" /><category term="ci-cd" /><category term="page-object-model" /><category term="complex-xpath" /><category term="complex-css" /><category term="svg" /><category term="shadow-dom" /><category term="iframe" /><category term="aria" /><category term="java" /><category term="python" /><category term="typescript" /><category term="javascript" /><summary type="html"><![CDATA[The SDET-flavored XPath pocket reference for Selenium, Playwright, and Cypress engineers. Locator priority pyramid, the 13 axes, the 10 functions you'll actually use, predicate recipes, multi-language code samples, framework gotchas, headless/CI failure modes, POM placement, observability hooks, **and an advanced section covering complex XPath (SVG, computed indices, ARIA chains, iframe/shadow DOM) and complex CSS (`:has()`, `:is()`/`:where()`, `:nth-child()`, attribute flags, sibling combinators)** — all in one bookmark-able page.]]></summary></entry><entry><title type="html">XPath for Test Automation: From “I Hate This” to “I Write It in My Sleep”</title><link href="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/" rel="alternate" type="text/html" title="XPath for Test Automation: From “I Hate This” to “I Write It in My Sleep”" /><published>2026-07-12T00:00:00+00:00</published><updated>2026-07-12T00:00:00+00:00</updated><id>https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation</id><content type="html" xml:base="https://veeresh-bikkaneti.github.io/techtalkwith-veeresh/automation/best-practices/tools/xpath-for-test-automation/"><![CDATA[<p>Most QA engineers have a complicated relationship with XPath.</p>

<p>It starts with <strong>fear</strong>. The first time you open a test suite and see <code class="language-plaintext highlighter-rouge">/html/body/div[1]/div[4]/form/div[2]/div[1]/div[1]/div/div[2]/input</code>, your eyes glaze over. Then comes <strong>frustration</strong> when a one-character CSS change breaks 87% of your tests at 2 AM. Eventually — usually after you write your third self-healing helper — you reach <strong>acceptance</strong>: XPath isn’t the enemy, writing XPath badly is.</p>

<p>This post walks the whole arc. You’ll learn:</p>

<ul>
  <li>A <strong>mental model</strong> that makes XPath syntax intuitive (no memorization required)</li>
  <li>The <strong>13 axes</strong> as directions on a DOM map (with a diagram you can stare at until it clicks)</li>
  <li>The <strong>10 functions</strong> you’ll actually use in day-to-day test work (and the 30 you can safely forget)</li>
  <li><strong>7 locator recipes</strong> fired up against real sites like the-internet.herokuapp — annotated, dev-tested, copy-paste-friendly</li>
  <li>The <strong>5 XPath mistakes</strong> I see in every code review (so you can be the one who catches them)</li>
  <li>How XPath <strong>fits into Playwright, Selenium, and Cypress</strong> — including the 2026 shortcut that changes the game</li>
</ul>

<p>Bookmark it for your first six months of automation work. When you’re ready to graduate from article to pocket reference, jump to the companion <strong><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">XPath Cheatsheet</a></strong> — dense, scannable, built to sit in your second monitor.</p>

<h2 id="in-this-post">In this post</h2>

<p>13 sections (one is a <code class="language-plaintext highlighter-rouge">9.5</code> interlude between §9 and §10). Story mode first, reference mode last. If you only need a desk reference, jump to the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">cheatsheet</a> or the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix/">translation appendix</a> instead.</p>

<ol>
  <li><strong><a href="#1-the-mental-model-your-dom-is-a-map">The Mental Model: Your DOM Is a Map</a>.</strong> The city-map metaphor and the four base templates that unlock every XPath you’ll write. <em>Read first.</em></li>
  <li><strong><a href="#2-the-locator-priority-pyramid">The Locator Priority Pyramid</a>.</strong> <code class="language-plaintext highlighter-rouge">data-testid</code> → ARIA role → ID → CSS → XPath. The decision tree you reach for before writing any locator.</li>
  <li><strong><a href="#3-axes-the-13-directions-on-your-dom-map">Axes — The 13 Directions on Your DOM Map</a>.</strong> All 13 navigation axes plus which five you’ll reach for in day-to-day work.</li>
  <li><strong><a href="#4-your-first-three-locators">Your First Three Locators</a>.</strong> Copy-paste templates: By ID, By stable attribute, By relationship to a known anchor.</li>
  <li><strong><a href="#5-functions-youll-actually-use-out-of-120">Functions You’ll Actually Use (Out of ~120)</a>.</strong> The 10 XPath 1.0 functions covering ~90% of work, plus which XPath 2.0+ versions silently fail.</li>
  <li><strong><a href="#6-predicates-the-real-power">Predicates — The Real Power</a>.</strong> The progressive-narrowing chain rule plus the <em>“row containing X”</em> idiom.</li>
  <li><strong><a href="#7-seven-locator-recipes-fired-up-against-real-sites">Seven Locator Recipes, Fired Up Against Real Sites</a>.</strong> 7 copy-paste-ready XPaths against <a href="https://the-internet.herokuapp.com/">the-internet.herokuapp.com</a> — login, dynamic button, table row, dropdown, checkout, current-nav, upload widget.</li>
  <li><strong><a href="#8-the-5-xpath-mistakes-i-see-in-every-code-review">The 5 XPath Mistakes I See in Every Code Review</a>.</strong> Absolute paths, indexed XPath, <code class="language-plaintext highlighter-rouge">contains(@class, "btn")</code> over-match, missing <code class="language-plaintext highlighter-rouge">normalize-space()</code>, missing iframe/shadow context.</li>
  <li><strong><a href="#9-xpath-across-tools-selenium-playwright-cypress">XPath Across Tools (Selenium, Playwright, Cypress)</a>.</strong> Same syntax, three APIs, plus the 2026 codegen macro-shift (<code class="language-plaintext highlighter-rouge">getByRole</code>, AI-generated locators).
    <ol>
      <li><strong>9.5. <a href="#sdet-playbook">The SDET Playbook — POM, Waits, CI, and Observability</a>.</strong> Locators as properties (not methods), wait contracts per framework, headless/CI pitfalls, failure-side observability. <em>The pivot from sandbox to production page-object code.</em></li>
    </ol>
  </li>
  <li><strong><a href="#10-self-healing-when-xpath-stops-behaving">Self-Healing: When XPath Stops Behaving</a>.</strong> Layered healing: XPath → ARIA semantic role → BiDi DOM diff.</li>
  <li><strong><a href="#11-the-12-question-refresher">The 12-Question Refresher</a>.</strong> Self-test before you bookmark, with expandable quick-answers folded.</li>
  <li><strong><a href="#12-beyond-the-basics-complex-xpath-css-for-sdets">Beyond the Basics: Complex XPath &amp; CSS for SDETs</a>.</strong> SVG namespace, computed indices, ARIA chains, iframe and shadow DOM piercing, modern CSS L4 (<code class="language-plaintext highlighter-rouge">:has()</code> / <code class="language-plaintext highlighter-rouge">:is()</code> / <code class="language-plaintext highlighter-rouge">:where()</code> / <code class="language-plaintext highlighter-rouge">:nth-child</code> formulas / sibling combinators / case-insensitive flag), the XPath-vs-CSS-vs-Playwright decision flowchart.</li>
</ol>

<blockquote>
  <p><strong>One-screen TL;DR:</strong> <a href="#2-the-locator-priority-pyramid">§2</a> (priority pyramid) + <a href="#5-functions-youll-actually-use-out-of-120">§5</a> (10 functions) + <a href="#7-seven-locator-recipes-fired-up-against-real-sites">§7</a> (7 recipes) + <a href="#8-the-5-xpath-mistakes-i-see-in-every-code-review">§8</a> (5 mistakes) is the 80/20 — read those four and you can write 80% of the locators you will ever need.</p>
</blockquote>

<pre><code class="language-mermaid">flowchart LR
    A["😱 Fear:&lt;br/&gt;pointer chains"] --&gt; B["😤 Frustration:&lt;br/&gt;tests break"] --&gt; C["🤝 Acceptance:&lt;br/&gt;stable strategy"] --&gt; D["💪 Fluency:&lt;br/&gt;self-healing"]
    style A fill:#f87171,color:#fff
    style B fill:#fbbf24,color:#000
    style C fill:#0ea5c7,color:#fff
    style D fill:#34d399,color:#000
</code></pre>

<hr />

<h2 id="1-the-mental-model-your-dom-is-a-map">1. The Mental Model: Your DOM Is a Map</h2>

<p>Before syntax, you need a picture in your head. Jargon locks you into memorization. Stories unlock intuition.</p>

<blockquote>
  <p><strong>Think of your DOM as a city.</strong> The <code class="language-plaintext highlighter-rouge">&lt;html&gt;</code> element is the city center. Each tag is a building. Each attribute is a sign on the building. Text is what’s painted on the walls. XPath is the directions you give a delivery driver: <em>“Go past the library, take the second left, find the red door.”</em></p>
</blockquote>

<p>XPath is therefore two things mashed together:</p>

<ol>
  <li><strong>A path</strong> — <em>where</em> in the DOM (which building)</li>
  <li><strong>A predicate</strong> — <em>which one</em> if there are many (which door)</li>
</ol>

<p>That’s it. Every XPath you’ve ever seen is some combination of <strong>path</strong> and <strong>predicate</strong>. Once that’s internalized, syntax demystifies itself.</p>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>What it answers</th>
      <th>Templated form</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">//tagname</code></td>
      <td><em>Where</em> in the DOM (any depth)</td>
      <td><code class="language-plaintext highlighter-rouge">//{tag}</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[@attr='value']</code></td>
      <td><em>Which one</em> (filter by attribute)</td>
      <td><code class="language-plaintext highlighter-rouge">[@{attr}='{value}']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[n]</code></td>
      <td><em>Which one</em> (the nth match)</td>
      <td><code class="language-plaintext highlighter-rouge">[{index}]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">function()</code></td>
      <td><em>Compound</em> which-one (contains, starts-with…)</td>
      <td><code class="language-plaintext highlighter-rouge">contains(@attr,'substring')</code></td>
    </tr>
  </tbody>
</table>

<p>The cheatsheet is built around these four templates. If you can plug values into brackets, you can write XPath.</p>

<details>
<summary><strong>🧪 Try it yourself (30 seconds)</strong></summary>

Open any browser → DevTools → Console. Run this:

```javascript
$x("//h1")[0]                                  // First h1 anywhere on the page
$x("//a[contains(@href,'github')]")            // All GitHub links
$x("//input[@type='checkbox' and not(@disabled)]") // Enabled checkboxes
document.querySelector("...") // CSS equivalent of above: input[type=checkbox]:not([disabled])
```

You're literally running XPATH from Chrome DevTools. Every time you face a tricky element, that's your sandbox. Use it.
</details>

<hr />

<h2 id="2-the-locator-priority-pyramid">2. The Locator Priority Pyramid</h2>

<p>Before you ever write an XPath, decide <strong>which kind</strong> of locator deserves the slot. Reach for the most stable rung first; only descend when you must.</p>

<pre><code class="language-mermaid">flowchart TD
    A["🏆 data-testid&lt;br/&gt;e.g. page.getByTestId 'submit'"] --&gt; B["♿ ARIA role and accessible name&lt;br/&gt;e.g. button name equals Submit"]
    B --&gt; C["🆔 Stable ID&lt;br/&gt;By.id equals checkout-btn"]
    C --&gt; D["📛 Stable attribute&lt;br/&gt;e.g. name equals email or href contains checkout"]
    D --&gt; E["🎨 CSS selector&lt;br/&gt;.checkout-form .submit"]
    E --&gt; F["📍 XPath relative&lt;br/&gt;button with text Pay now"]
    F --&gt; G["📏 XPath absolute - AVOID&lt;br/&gt;/html/body/div[1]/.../button"]
    G --&gt; H["💀 Indexed XPath&lt;br/&gt;div or div[3] or span[2] or button - AVOID"]

    style A fill:#34d399,color:#000
    style B fill:#34d399,color:#000
    style C fill:#a78bfa,color:#000
    style D fill:#a78bfa,color:#000
    style E fill:#fbbf24,color:#000
    style F fill:#fbbf24,color:#000
    style G fill:#f87171,color:#fff
    style H fill:#f87171,color:#fff
</code></pre>

<p><strong>Read it as a decision tree, not a ranking.</strong> Every rung is the <em>right</em> choice in some situation:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">data-testid</code> and ARIA</strong> are co-equal champions in 2026. Playwright even auto-generates them via codegen.</li>
  <li><strong>Stable IDs</strong> are fine when the frontend team keeps them (most do for inputs).</li>
  <li><strong>CSS selectors</strong> win on speed and readability for layout-driven queries.</li>
  <li><strong>Relative XPath</strong> wins when the screen is data-driven — tables, dynamic lists, repeated rows.</li>
  <li><strong>Absolute XPath</strong> is documentation, not testing. If you find yourself writing <code class="language-plaintext highlighter-rouge">/html/body/div[1]</code>, stop and write a Page Object or a <code class="language-plaintext highlighter-rouge">data-testid</code> request instead.</li>
  <li><strong>Indexed XPath</strong> (the kind that breaks when you add a column to a table) is the silent killer of test suites. Extract a helper or use a relative position function (<code class="language-plaintext highlighter-rouge">following-sibling::td[2]</code>).</li>
</ul>

<blockquote>
  <p>The 2020 Selenium guide in this blog called ID/class/CSS/XPath the strategy ladder. Six years later, <strong><code class="language-plaintext highlighter-rouge">data-testid</code> and ARIA replaced them at the top</strong>, and <strong>relative XPath replaced absolute XPath at the bottom</strong>. Same ladder, healthier defaults.</p>
</blockquote>

<hr />

<h2 id="3-axes-the-13-directions-on-your-dom-map">3. Axes — The 13 Directions on Your DOM Map</h2>

<p>Axes are XPath’s superpower. Read them as <strong>relationships</strong>, not syntax.</p>

<p><strong>The 5 you use 90% of the time:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">//tag</code> (descendant) — any depth below</li>
  <li><code class="language-plaintext highlighter-rouge">tag</code> (child) — direct children only</li>
  <li><code class="language-plaintext highlighter-rouge">..</code> (parent) — one level up</li>
  <li><code class="language-plaintext highlighter-rouge">ancestor::tag</code> — up any levels</li>
  <li><code class="language-plaintext highlighter-rouge">/following-sibling::tag</code> — same parent, after this node</li>
</ul>

<p><strong>The 8 rare ones:</strong> <code class="language-plaintext highlighter-rouge">preceding-sibling::</code> (before on same level), <code class="language-plaintext highlighter-rouge">preceding::</code> (anywhere before), <code class="language-plaintext highlighter-rouge">following::</code> (anywhere after), <code class="language-plaintext highlighter-rouge">ancestor-or-self::</code> (up or self), <code class="language-plaintext highlighter-rouge">descendant-or-self::</code> (<code class="language-plaintext highlighter-rouge">//</code> is this), <code class="language-plaintext highlighter-rouge">self::</code> (<code class="language-plaintext highlighter-rouge">.</code>), <code class="language-plaintext highlighter-rouge">attribute::</code> (<code class="language-plaintext highlighter-rouge">@</code>), <code class="language-plaintext highlighter-rouge">namespace::</code> (SVG/XML only).</p>

<p><strong>Shortcuts you already use:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>//tag    = anywhere    |    /tag    = direct child    |    ..    = parent
.        = self        |    @attr   = attribute
</code></pre></div></div>

<p>Example: Find expiry input in checkout form:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">//form[@id='checkout']//input[@name='expiry']</code> (descendant axis)</li>
  <li><code class="language-plaintext highlighter-rouge">//input[@name='card']/following-sibling::input</code> (sibling axis)</li>
  <li><code class="language-plaintext highlighter-rouge">//input[@name='expiry']/ancestor::form</code> (find parent form — CSS can’t do this)</li>
</ul>

<hr />

<h2 id="4-your-first-three-locators">4. Your First Three Locators</h2>

<p>Let’s plant three flags you’ll use thousands of times:</p>

<h3 id="by-id-fastest-most-readable">By ID (fastest, most readable)</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//*</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'username'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'app'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="by-stable-attribute">By stable attribute</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@name</span><span class="o">=</span><span class="s">'email'</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">a</span><span class="p">[</span><span class="nf">starts-with</span><span class="p">(</span><span class="na">@href</span><span class="o">,</span><span class="s">'/checkout/'</span><span class="p">)]</span><span class="w">
</span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'submit'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<h3 id="by-relationship-to-a-known-anchor">By relationship to a known anchor</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">label</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Email'</span><span class="p">]</span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">input</span><span class="w">
</span><span class="o">//</span><span class="nt">h2</span><span class="p">[</span><span class="k">text</span><span class="p">()</span><span class="o">=</span><span class="s">'Order summary'</span><span class="p">]</span><span class="o">/</span><span class="k">ancestor</span><span class="o">::</span><span class="nt">section</span><span class="o">//</span><span class="nt">button</span><span class="w">
</span></code></pre></div></div>

<p>That last example is worth dissecting — it’s the kind of locator that survives redesigns:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">h2[text()='Order summary']</code> — find the <strong>header</strong> for the section you care about (semantic anchor)</li>
  <li><code class="language-plaintext highlighter-rouge">ancestor::section</code> — climb to the <strong>container</strong> of that section</li>
  <li><code class="language-plaintext highlighter-rouge">//button</code> — find any button <strong>inside</strong> that container</li>
</ul>

<p>Now if a developer renames <code class="language-plaintext highlighter-rouge">section.checkout-panel</code> to <code class="language-plaintext highlighter-rouge">.order-summary-card</code> or moves the section into a <code class="language-plaintext highlighter-rouge">&lt;div role="dialog"&gt;</code>, your locator doesn’t care. It still finds that section by what it <strong>says</strong>, not what it’s <strong>called</strong>.</p>

<hr />

<h2 id="5-functions-youll-actually-use-out-of-120">5. Functions You’ll Actually Use (Out of ~120)</h2>

<p>XPath ships with <strong>120+ functions</strong>. You’ll touch <strong>10 of them</strong> in 90% of the work you do. Here are those 10, in priority order:</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>Purpose</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">text()</code></td>
      <td>Match exact visible text</td>
      <td><code class="language-plaintext highlighter-rouge">//button[text()='Pay now']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">contains(@a, 'sub')</code></td>
      <td>Substring match on attribute</td>
      <td><code class="language-plaintext highlighter-rouge">//a[contains(@href,'/orders/')]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">normalize-space()</code></td>
      <td>Collapses whitespace, trims</td>
      <td><code class="language-plaintext highlighter-rouge">//h1[normalize-space()='Welcome']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">starts-with(@a, 'pre')</code></td>
      <td>Prefix match</td>
      <td><code class="language-plaintext highlighter-rouge">//div[starts-with(@class,'order-')]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">translate(s, 'A..Z', 'a..z')</code></td>
      <td>Case-insensitive match (XPath 1.0 idiom)</td>
      <td><code class="language-plaintext highlighter-rouge">//a[translate(@href,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='/help']</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">string-length()</code></td>
      <td>Length tests</td>
      <td><code class="language-plaintext highlighter-rouge">//input[string-length(@value)=0]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">not()</code></td>
      <td>Negate a condition</td>
      <td><code class="language-plaintext highlighter-rouge">//input[@type='checkbox' and not(@checked)]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">count()</code></td>
      <td>Count matches</td>
      <td><code class="language-plaintext highlighter-rouge">//table//tr[count(td)=5]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">position()</code> / <code class="language-plaintext highlighter-rouge">last()</code></td>
      <td>Positional access</td>
      <td><code class="language-plaintext highlighter-rouge">//ul/li[last()]</code> · <code class="language-plaintext highlighter-rouge">//ul/li[position()=2]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">substring(s, start, len)</code></td>
      <td>Slicing strings</td>
      <td><code class="language-plaintext highlighter-rouge">//td[substring(text(),1,3)='INV']</code></td>
    </tr>
  </tbody>
</table>

<details>
<summary><strong>🧠 Why only 10?</strong></summary>

Because XPath 1.0 (the version every browser &amp; WebDriver binding supports out of the box) is intentionally small. XPath 2.0+ adds a much richer function library — but Playwright/Selenium/Cypress all evaluate **XPath 1.0** in their built-in locators. If you write `for $i in (1 to 10) return //li[$i]` thinking you've leveled up, you're in for a surprise. Stick to the 10 above until you migrate to XSLT-style tooling.
</details>

<hr />

<h2 id="6-predicates-the-real-power">6. Predicates — The Real Power</h2>

<p>A predicate is anything inside square brackets <code class="language-plaintext highlighter-rouge">[...]</code> that filters nodes. Chains of predicates are how XPath turns “any button” into “the third enabled button inside the second form on this page.”</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Apples'</span><span class="p">]]</span><span class="o">/</span><span class="nt">td</span><span class="p">[</span><span class="m">3</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@class</span><span class="o">=</span><span class="s">'alert'</span><span class="p">][</span><span class="nf">count</span><span class="p">(</span><span class="o">.//</span><span class="nt">li</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="m">3</span><span class="p">]</span><span class="w">
</span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'radio'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@name</span><span class="o">=</span><span class="s">'ship'</span><span class="p">][</span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<p>The chain rule, made memorable:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>axis::tag[predicate1][predicate2]…
</code></pre></div></div>

<p>Each <code class="language-plaintext highlighter-rouge">[predicate]</code> is <strong>one filter</strong> applied to the result of the previous step. They multiply, they don’t add.</p>

<h3 id="the-reliable-way-to-grab-the-row-that-contains-x">The reliable way to grab “the row that contains X”</h3>

<p>This is the #1 thing every table-heavy test needs:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Smith'</span><span class="p">]]</span><span class="o">/</span><span class="nt">td</span><span class="p">[</span><span class="m">4</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tr[td[normalize-space()='Smith']]</code> — find a row whose <strong>any cell</strong> says “Smith”</p>

<p><code class="language-plaintext highlighter-rouge">/td[4]</code> — then take the 4th cell of that row (price, status, action — pick whatever column).</p>

<p>Without this trick, you’d need to count rows by index, which breaks when someone sorts the table. With this trick, sorting doesn’t matter.</p>

<hr />

<h2 id="7-seven-locator-recipes-fired-up-against-real-sites">7. Seven Locator Recipes, Fired Up Against Real Sites</h2>

<p>These are real locators against <a href="https://the-internet.herokuapp.com/">the-internet.herokuapp.com</a> — an open-by-design playground, no auth needed, free to use in your own framework.</p>

<h3 id="recipe-1--login-form-username-field">Recipe #1 — Login form: username field</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">form</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'login'</span><span class="p">]</span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="na">@id</span><span class="o">=</span><span class="s">'username'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>The HTML is <code class="language-plaintext highlighter-rouge">&lt;form id="login"&gt;&lt;input id="username"&gt;…</code>. Anchor on the <strong>form ID</strong>, then descend. Survives any surrounding markup changes.</p>

<h3 id="recipe-2--dynamic-button-with-visible-label">Recipe #2 — Dynamic button with visible label</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">(</span><span class="o">.</span><span class="p">)</span><span class="o">=</span><span class="s">'Add to cart'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">normalize-space(.)</code> matches the <strong>whole visible text</strong> of the button, not just one text child. <code class="language-plaintext highlighter-rouge">not(@disabled)</code> adds a guard so you don’t try to click before AJAX finishes.</p>

<h3 id="recipe-3--checkbox-inside-a-table-row-by-row-label">Recipe #3 — Checkbox inside a table row by row label</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="w">
    </span><span class="nt">td</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Delete user bob@x.com'</span><span class="p">]</span><span class="w">
</span><span class="p">]</span><span class="o">/</span><span class="nt">td</span><span class="o">/</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'checkbox'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Finds any row whose first cell is that email, then narrows to its checkbox. Independent of row position.</p>

<h3 id="recipe-4--drop-down-option-containing-a-substring">Recipe #4 — Drop-down option containing a substring</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">select</span><span class="p">[</span><span class="na">@name</span><span class="o">=</span><span class="s">'country'</span><span class="p">]</span><span class="o">/</span><span class="nt">option</span><span class="p">[</span><span class="w">
    </span><span class="nf">contains</span><span class="p">(</span><span class="o">.,</span><span class="w"> </span><span class="s">'United'</span><span class="p">)</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Use <code class="language-plaintext highlighter-rouge">contains(., '…')</code> to match against <strong>all text descendants</strong>, not just the <code class="language-plaintext highlighter-rouge">@value</code> attribute. Both work; pick by what’s stable in your AUT.</p>

<h3 id="recipe-5--submit-button-near-last-input-in-a-checkout-form">Recipe #5 — Submit button near last input in a checkout form</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">form</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="na">@class</span><span class="o">,</span><span class="s">'checkout'</span><span class="p">)]</span><span class="w">
    </span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="nf">last</span><span class="p">()]</span><span class="w">
    </span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">button</span><span class="p">[</span><span class="w">
        </span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Place order'</span><span class="w">
    </span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Level 4 locator: works even when the design team reorders inputs or rebrands the button class. Reads like English.</p>

<h3 id="recipe-6--the-active-item-in-a-navigation-list">Recipe #6 — The “active” item in a navigation list</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">nav</span><span class="o">//</span><span class="nt">li</span><span class="p">[</span><span class="nt">a</span><span class="p">[</span><span class="na">@aria-current</span><span class="o">=</span><span class="s">'page'</span><span class="p">]]</span><span class="w">
    </span><span class="o">/</span><span class="nt">a</span><span class="w">
</span></code></pre></div></div>

<p>Find the list item whose anchor carries <code class="language-plaintext highlighter-rouge">aria-current='page'</code> (the standard accessibility attribute for “this is the page you’re on”), then grab its anchor. Pattern works for any “current page” indicator — nav, breadcrumbs, tabs. <strong>Bonus:</strong> it actually matches design intent, not class strings, so it survives redesigns without renaming.</p>

<h3 id="recipe-7--file-input-inside-a-custom-upload-component">Recipe #7 — File input inside a custom upload component</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">label</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Upload avatar'</span><span class="p">]</span><span class="w">
    </span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">input</span><span class="p">[</span><span class="na">@type</span><span class="o">=</span><span class="s">'file'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Many upload widgets wrap a real <code class="language-plaintext highlighter-rouge">&lt;input type="file"&gt;</code> inside a styled <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code>. Find the <strong>label by what it says</strong>, then walk right to the input via <code class="language-plaintext highlighter-rouge">following-sibling</code>. This anchors on user-visible intent (the label text), not on the wrapper’s CSS class — so it survives redesigns of the upload widget.</p>

<details>
<summary><strong>🔍 Want to test these yourself?</strong></summary>

```bash
# Firefox DevTools console
$x("//form[@id='login']//input[@id='username']")

# Chrome DevTools – use $x() the same way
$x("//article[contains(@class,'post')]")

# Playwright inspector (codegen mode generates real XPath)
npx playwright codegen https://the-internet.herokuapp.com/login
```

If `$x()` returns `[object Element]` you found one. If it returns `[]` you haven't — adjust the path.
</details>

<hr />

<h2 id="8-the-5-xpath-mistakes-i-see-in-every-code-review">8. The 5 XPath Mistakes I See in Every Code Review</h2>

<p>If you internalize these five, you’ll be ahead of 80% of test automation engineers:</p>

<h3 id="mistake-1--absolute-paths">Mistake #1 — Absolute paths</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">/</span><span class="nt">html</span><span class="o">/</span><span class="nt">body</span><span class="o">/</span><span class="ow">div</span><span class="p">[</span><span class="m">1</span><span class="p">]</span><span class="o">/</span><span class="ow">div</span><span class="p">[</span><span class="m">2</span><span class="p">]</span><span class="o">/</span><span class="nt">main</span><span class="o">/</span><span class="nt">section</span><span class="p">[</span><span class="m">3</span><span class="p">]</span><span class="o">/</span><span class="nt">form</span><span class="o">/</span><span class="nt">button</span><span class="w">
</span></code></pre></div></div>

<p>Anything changes in the layout — your tests explode. <strong>Always start with <code class="language-plaintext highlighter-rouge">//</code> or with a stable anchor.</strong></p>

<h3 id="mistake-2--index-dependence-on-dynamic-content">Mistake #2 — Index dependence on dynamic content</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="m">3</span><span class="p">]</span><span class="o">/</span><span class="nt">button</span><span class="w">
</span></code></pre></div></div>

<p>The third list item is rarely the third you need. <strong>Use predicates</strong> (<code class="language-plaintext highlighter-rouge">li[a[text()='Delete']]/button</code>) <strong>or text content</strong> (<code class="language-plaintext highlighter-rouge">//button[normalize-space()='Delete']</code>).</p>

<h3 id="mistake-3--containsclass-btn-matching-too-much">Mistake #3 — <code class="language-plaintext highlighter-rouge">contains(@class, 'btn')</code> matching too much</h3>

<p>A class named <code class="language-plaintext highlighter-rouge">button-group</code> contains the substring <code class="language-plaintext highlighter-rouge">button</code>. So does <code class="language-plaintext highlighter-rouge">btn-primary</code>. <code class="language-plaintext highlighter-rouge">btn-dark</code>. A bare <code class="language-plaintext highlighter-rouge">contains(@class,'btn')</code> matches all of them. Be precise:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">contains</span><span class="p">(</span><span class="nf">concat</span><span class="p">(</span><span class="s">' '</span><span class="o">,</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">(</span><span class="na">@class</span><span class="p">)</span><span class="o">,</span><span class="w"> </span><span class="s">' '</span><span class="p">)</span><span class="o">,</span><span class="w"> </span><span class="s">' btn-primary '</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<p>The space-padded trick is the canonical XPath 1.0 way to do what CSS does with <code class="language-plaintext highlighter-rouge">.btn-primary</code>. Ugly. But it works.</p>

<blockquote>
  <p><strong>Better:</strong> prefer <code class="language-plaintext highlighter-rouge">data-testid</code> if you can ask the frontend team for one. Playwright’s <code class="language-plaintext highlighter-rouge">getByTestId()</code> uses the same attribute convention.</p>
</blockquote>

<h3 id="mistake-4--not-normalizing-whitespace">Mistake #4 — Not normalizing whitespace</h3>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">h1</span><span class="p">[</span><span class="k">text</span><span class="p">()</span><span class="o">=</span><span class="s">'Welcome'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Fails when the page renders as <code class="language-plaintext highlighter-rouge">&lt;h1&gt;\n  Welcome\n&lt;/h1&gt;</code>. Use <code class="language-plaintext highlighter-rouge">normalize-space()</code>:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="nt">h1</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Welcome'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="mistake-5--crossing-into-iframes--shadow-dom-without-realizing-it">Mistake #5 — Crossing into iframes / shadow DOM without realizing it</h3>

<p>XPath <strong>cannot pierce iframes or shadow DOM</strong> out of the box. If your element is inside an <code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code>, switch the driver context first:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">driver</span><span class="o">.</span><span class="na">switchTo</span><span class="o">().</span><span class="na">frame</span><span class="o">(</span><span class="s">"payment-iframe"</span><span class="o">);</span>
<span class="c1">// Now your XPath runs inside that frame</span>
</code></pre></div></div>

<p>For shadow DOM, you need <code class="language-plaintext highlighter-rouge">shadowRoot.evaluate()</code> (Playwright/JS) or <code class="language-plaintext highlighter-rouge">driver.findElement(By.cssSelector("css-that-pierces-shadow"))</code> — XPath on its own stops at the shadow boundary. Use <strong>Playwright’s <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> shadow-piercing combinator</strong> or <strong>Selenium’s JavaScript executor</strong> for shadow DOM:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — pierces shadow DOM with the &gt;&gt;&gt; combinator</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">payment-form &gt;&gt;&gt; button.pay</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<hr />

<h2 id="9-xpath-across-tools-selenium-playwright-cypress">9. XPath Across Tools (Selenium, Playwright, Cypress)</h2>

<p>The syntax is identical. Only the API call changes:</p>

<h3 id="java--selenium">Java — Selenium</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">WebElement</span> <span class="n">username</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span>
    <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//form[@id='login']//input[@id='username']"</span><span class="o">)</span>
<span class="o">);</span>
</code></pre></div></div>

<h3 id="python--selenium">Python — Selenium</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">username</span> <span class="o">=</span> <span class="n">driver</span><span class="p">.</span><span class="n">find_element</span><span class="p">(</span>
    <span class="n">By</span><span class="p">.</span><span class="n">XPATH</span><span class="p">,</span> <span class="s">"//form[@id='login']//input[@id='username']"</span>
<span class="p">)</span>
</code></pre></div></div>

<h3 id="playwright-typescript--javascript">Playwright (TypeScript / JavaScript)</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">"</span><span class="s2">bob</span><span class="dl">"</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">xpath=//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">"</span><span class="s2">bob</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">xpath=</code> prefix is <strong>optional</strong> but recommended — it’s portable to other engines.</p>

<h3 id="cypress">Cypress</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="dl">"</span><span class="s2">//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">).</span><span class="nx">type</span><span class="p">(</span><span class="dl">"</span><span class="s2">bob</span><span class="dl">"</span><span class="p">);</span>
<span class="c1">// Or, with the cypress-xpath plugin</span>
</code></pre></div></div>

<h3 id="visual-selector-comparison-same-dom-three-tools">Visual selector comparison (same DOM, three tools)</h3>

<pre><code class="language-mermaid">flowchart LR
    DOM["🧾 DOM:&lt;br/&gt;form id=login →&lt;br/&gt;input id=username"] --&gt; SEL["💎 Selenium&lt;br/&gt;By.xpath '//form…input'"]
    DOM --&gt; PW["🎭 Playwright&lt;br/&gt;page.locator 'xpath=…'"]
    DOM --&gt; CY["🌲 Cypress&lt;br/&gt;cy.xpath '…'"]
    SEL --&gt; T1["✅ Element"]
    PW --&gt; T1
    CY --&gt; T1
</code></pre>

<h3 id="2026-macro-shift-codegen-beats-hand-typing">2026 macro-shift: codegen beats hand-typing</h3>

<p>Both Selenium 4 and Playwright ship <strong>codegen</strong> that emits locators automatically. With the Selenium MCP Server (see <a href="/techtalkwith-veeresh/automation/tools/selenium-2026-beginners-guide/">Selenium in 2026: Beginner’s Guide</a>), an AI agent can <strong>look at a screenshot and write the XPath for you</strong>. Your job shifts from “compose XPath in your head” → “read XPath, decide if it’s stable enough, name it, ship it.”</p>

<hr />

<h2 id="sdet-playbook">9.5 The SDET Playbook — POM, Waits, CI, and Observability</h2>

<p>The XPaths in §7 work in a sandbox. Real SDET code lives behind a <strong>Page Object Model</strong>, runs in <strong>headless CI</strong>, and breaks when the <em>ecosystem</em> around the locator isn’t right. Here’s the playbook for the four failure modes that show up in every team’s CI logs.</p>

<h3 id="where-xpath-lives-in-your-pom">Where XPath lives in your POM</h3>

<p>The locator is <strong>a property, not a method</strong>. Define once on the page object, reuse by name from every spec.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pages/LoginPage.ts — Playwright example</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nx">LoginPage</span> <span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">;</span>       <span class="c1">// declared once, assigned in constructor</span>
  <span class="k">readonly</span> <span class="nx">form</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>            <span class="c1">// initialized in constructor body</span>
  <span class="k">readonly</span> <span class="nx">username</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">password</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">submitBtn</span><span class="p">:</span> <span class="nx">Locator</span><span class="p">;</span>

  <span class="kd">constructor</span><span class="p">(</span><span class="nx">page</span><span class="p">:</span> <span class="nx">Page</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">page</span>    <span class="o">=</span> <span class="nx">page</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">form</span>     <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//form[@id='login']</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">username</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//input[@id='username']</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">password</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//input[@id='password']</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">submitBtn</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">form</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span>
      <span class="dl">"</span><span class="s2">//button[normalize-space()='Login' and not(@disabled)]</span><span class="dl">"</span>
    <span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">loginAs</span><span class="p">(</span><span class="nx">user</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">pass</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">username</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">password</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="nx">pass</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">submitBtn</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p><strong>TS pitfall:</strong> declaring locators as <code class="language-plaintext highlighter-rouge">readonly foo = this.page.locator(...)</code> (when <code class="language-plaintext highlighter-rouge">page</code> is declared as a class field without parameter-property syntax — so field initializers run before the constructor-body assignment) trips <code class="language-plaintext highlighter-rouge">strictPropertyInitialization</code> in strict TS configs. Initialize in the constructor body — that pattern compiles cleanly under any <code class="language-plaintext highlighter-rouge">strict</code> flag and survives test fixture inheritance.</p>
</blockquote>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pages/LoginPage.java — Selenium + Java</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoginPage</span> <span class="o">{</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">;</span>
  <span class="kd">public</span> <span class="nf">LoginPage</span><span class="o">(</span><span class="nc">WebDriver</span> <span class="n">driver</span><span class="o">)</span> <span class="o">{</span> <span class="k">this</span><span class="o">.</span><span class="na">driver</span> <span class="o">=</span> <span class="n">driver</span><span class="o">;</span> <span class="o">}</span>

  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">form</span>      <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//form[@id='login']"</span><span class="o">);</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">username</span>  <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//form[@id='login']//input[@id='username']"</span><span class="o">);</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">By</span> <span class="n">submitBtn</span> <span class="o">=</span> <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span>
      <span class="s">"//button[normalize-space()='Login' and not(@disabled)]"</span>
  <span class="o">);</span>

  <span class="kd">public</span> <span class="nc">LoginPage</span> <span class="nf">loginAs</span><span class="o">(</span><span class="nc">String</span> <span class="n">user</span><span class="o">,</span> <span class="nc">String</span> <span class="n">pass</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">username</span><span class="o">).</span><span class="na">sendKeys</span><span class="o">(</span><span class="n">user</span><span class="o">);</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">id</span><span class="o">(</span><span class="s">"password"</span><span class="o">)).</span><span class="na">sendKeys</span><span class="o">(</span><span class="n">pass</span><span class="o">);</span>
    <span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="n">submitBtn</span><span class="o">).</span><span class="na">click</span><span class="o">();</span>
    <span class="k">return</span> <span class="k">this</span><span class="o">;</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// cypress/pages/login-page.js — Cypress</span>
<span class="kd">const</span> <span class="nx">login</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">form</span><span class="p">:</span>      <span class="dl">"</span><span class="s2">//form[@id='login']</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">username</span><span class="p">:</span>  <span class="dl">"</span><span class="s2">//form[@id='login']//input[@id='username']</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">submitBtn</span><span class="p">:</span> <span class="dl">"</span><span class="s2">//button[normalize-space()='Login' and not(@disabled)]</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">loginAs</span> <span class="o">=</span> <span class="p">(</span><span class="nx">user</span><span class="p">,</span> <span class="nx">pass</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="nx">login</span><span class="p">.</span><span class="nx">username</span><span class="p">).</span><span class="nx">type</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
  <span class="nx">cy</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">#password</span><span class="dl">'</span><span class="p">).</span><span class="nx">type</span><span class="p">(</span><span class="nx">pass</span><span class="p">);</span>
  <span class="nx">cy</span><span class="p">.</span><span class="nx">xpath</span><span class="p">(</span><span class="nx">login</span><span class="p">.</span><span class="nx">submitBtn</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="p">};</span>
</code></pre></div></div>

<p><strong>Rule of thumb:</strong> locators as <code class="language-plaintext highlighter-rouge">By</code>/<code class="language-plaintext highlighter-rouge">Locator</code> constants on the page object — <strong>never inline strings in test specs.</strong> Inline strings defeat the SDET value proposition: one rename should fix 200 specs, not 200 spec files.</p>

<h3 id="wait-strategies-that-match-your-framework">Wait strategies that match your framework</h3>

<p>A correct XPath still times out if the framework’s wait contract is wrong. Match the contract to the tool:</p>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Default wait</th>
      <th>What to do</th>
      <th>Anti-pattern (causes CI flakiness)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Selenium 4</strong></td>
      <td>None — <code class="language-plaintext highlighter-rouge">findElement</code> is a single DOM query</td>
      <td>Wrap in <code class="language-plaintext highlighter-rouge">WebDriverWait(expected).until(EC.visibilityOfElementLocated(by))</code></td>
      <td>Relying on <code class="language-plaintext highlighter-rouge">Thread.sleep(2000)</code> to be safe</td>
    </tr>
    <tr>
      <td><strong>Playwright</strong></td>
      <td>Auto-wait up to 30s on every action</td>
      <td>Use <code class="language-plaintext highlighter-rouge">await locator.click()</code> directly; chain <code class="language-plaintext highlighter-rouge">await expect(locator).toBeVisible()</code> for asserts</td>
      <td>Redundant <code class="language-plaintext highlighter-rouge">.waitFor()</code> before <code class="language-plaintext highlighter-rouge">.click()</code> — <code class="language-plaintext highlighter-rouge">.click()</code> already auto-waits for actionability</td>
    </tr>
    <tr>
      <td><strong>Cypress</strong></td>
      <td>Auto-retry on <code class="language-plaintext highlighter-rouge">cy.get()</code> for 4s by default</td>
      <td>Use <code class="language-plaintext highlighter-rouge">cy.xpath(...).should('be.visible')</code> for assertion, <code class="language-plaintext highlighter-rouge">.click()</code>/<code class="language-plaintext highlighter-rouge">.type()</code> for action</td>
      <td>Calling <code class="language-plaintext highlighter-rouge">.then()</code> and losing the retry chain</td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — preferred</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//button[normalize-space()='Pay' and not(@disabled)]</span><span class="dl">"</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>

<span class="c1">// Playwright — verbose equivalent (DON'T do this — .waitFor() is redundant)</span>
<span class="kd">const</span> <span class="nx">btn</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//button[normalize-space()='Pay' and not(@disabled)]</span><span class="dl">"</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">btn</span><span class="p">.</span><span class="nx">waitFor</span><span class="p">();</span>      <span class="c1">// redundant: .click() already auto-waits for actionability</span>
<span class="k">await</span> <span class="nx">btn</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium — preferred</span>
<span class="k">new</span> <span class="nf">WebDriverWait</span><span class="o">(</span><span class="n">driver</span><span class="o">,</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">10</span><span class="o">))</span>
    <span class="o">.</span><span class="na">until</span><span class="o">(</span><span class="n">d</span> <span class="o">-&gt;</span> <span class="o">{</span>
      <span class="nc">WebElement</span> <span class="n">b</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span>
          <span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//button[normalize-space()='Pay' and not(@disabled)]"</span><span class="o">)</span>
      <span class="o">);</span>
      <span class="k">return</span> <span class="n">b</span><span class="o">.</span><span class="na">isDisplayed</span><span class="o">()</span> <span class="o">?</span> <span class="n">b</span> <span class="o">:</span> <span class="kc">null</span><span class="o">;</span>
    <span class="o">})</span>
    <span class="o">.</span><span class="na">click</span><span class="o">();</span>
</code></pre></div></div>

<p>A wrong wait contract turns a correct XPath into a flaky test. <strong>Always match the contract above before blaming the XPath.</strong></p>

<h3 id="headless--ci-mode-pitfalls">Headless / CI mode pitfalls</h3>

<p>Three patterns that always bite in CI but pass on a laptop:</p>

<ol>
  <li>
    <p><strong>Viewport size differences.</strong> Headless Chrome’s default viewport is 1280×720; CI runners may default smaller. An XPath that finds an element on your 1920×1080 laptop may resolve off-screen in CI. Set explicit viewport in Playwright: <code class="language-plaintext highlighter-rouge">await page.setViewportSize({ width: 1280, height: 720 })</code>.</p>
  </li>
  <li><strong>Animation timing.</strong> <code class="language-plaintext highlighter-rouge">display: none</code> → fade-in takes 200ms on a laptop, may stretch to 800ms under load on CI. Anchor on <strong>state</strong>, not animation completion:
    <div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'status'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Loaded'</span><span class="p">]</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li><strong>GPU rendering.</strong> CSS transforms that pass <code class="language-plaintext highlighter-rouge">isVisible()</code> server-side may render after the screenshot fires. Use <code class="language-plaintext highlighter-rouge">await locator.waitFor({ state: 'visible' })</code> <strong>before</strong> taking the screenshot, not after.</li>
</ol>

<h3 id="observability--capture-debug-artifacts-on-failure">Observability — capture debug artifacts on failure</h3>

<p>When a test fails in CI, you need three things: <strong>the HTML at the moment of failure</strong>, <strong>a screenshot</strong>, and <strong>the network log</strong>. Wire them into each framework from day one:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — config-level failure hook (already built-in)</span>
<span class="c1">// playwright.config.ts</span>
<span class="k">export</span> <span class="k">default</span> <span class="p">{</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">trace</span><span class="p">:</span> <span class="dl">'</span><span class="s1">retain-on-failure</span><span class="dl">'</span><span class="p">,</span>     <span class="c1">// full trace on failure</span>
    <span class="na">screenshot</span><span class="p">:</span> <span class="dl">'</span><span class="s1">only-on-failure</span><span class="dl">'</span><span class="p">,</span>  <span class="c1">// PNG snapshot</span>
    <span class="na">video</span><span class="p">:</span> <span class="dl">'</span><span class="s1">retain-on-failure</span><span class="dl">'</span><span class="p">,</span>     <span class="c1">// screencast</span>
  <span class="p">},</span>
<span class="p">};</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium + TestNG — listener</span>
<span class="nd">@AfterMethod</span><span class="o">(</span><span class="n">alwaysRun</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">capture</span><span class="o">(</span><span class="nc">ITestResult</span> <span class="n">result</span><span class="o">)</span> <span class="o">{</span>
  <span class="k">if</span> <span class="o">(</span><span class="n">result</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">==</span> <span class="nc">ITestResult</span><span class="o">.</span><span class="na">FAILURE</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">WebDriver</span> <span class="n">driver</span> <span class="o">=</span> <span class="nc">DriverFactory</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
    <span class="o">((</span><span class="nc">TakesScreenshot</span><span class="o">)</span> <span class="n">driver</span><span class="o">)</span>
        <span class="o">.</span><span class="na">getScreenshotAs</span><span class="o">(</span><span class="nc">OutputType</span><span class="o">.</span><span class="na">FILE</span><span class="o">);</span>   <span class="c1">// → save + attach to report</span>
    <span class="nc">String</span> <span class="n">html</span> <span class="o">=</span> <span class="n">driver</span><span class="o">.</span><span class="na">getPageSource</span><span class="o">();</span>   <span class="c1">// → attach to report</span>
    <span class="c1">// Log the failed locator (set via a ThreadLocal in your helper)</span>
    <span class="nc">Reporter</span><span class="o">.</span><span class="na">log</span><span class="o">(</span><span class="s">"Failed XPath: "</span> <span class="o">+</span> <span class="nc">LastLocatorHolder</span><span class="o">.</span><span class="na">get</span><span class="o">(),</span> <span class="kc">true</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Cypress — automatic via cypress-on-fix</span>
<span class="c1">// cypress.config.ts</span>
<span class="p">{</span>
  <span class="nl">e2e</span><span class="p">:</span> <span class="p">{</span>
    <span class="nx">setupNodeEvents</span><span class="p">(</span><span class="nx">on</span><span class="p">,</span> <span class="nx">config</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">cypress-on-fix</span><span class="dl">'</span><span class="p">)(</span><span class="nx">on</span><span class="p">);</span>   <span class="c1">// auto-captures HTML + screenshot on failure</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>The XPath that failed is the <em>first</em> thing you want printed.</strong> Most teams log the locator string + failed predicate to the report next to the screenshot. Without it, you’re staring at a PNG guessing which of 41 buttons in the hierarchy is the broken one.</p>

<h3 id="the-sdet--frontend-data-testid-contract">The SDET ↔ Frontend data-testid contract</h3>

<p>You own the <strong>naming convention</strong>, not individual test IDs. Negotiate this upfront with the frontend team:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Frontend convention (one-line in the style guide)</span>
<span class="c1">// `data-testid="&lt;page&gt;-&lt;component&gt;-&lt;intent&gt;"`</span>

<span class="o">&lt;</span><span class="nx">button</span> <span class="nx">data</span><span class="o">-</span><span class="nx">testid</span><span class="o">=</span><span class="dl">"</span><span class="s2">checkout-pay-button</span><span class="dl">"</span><span class="o">&gt;</span><span class="nx">Pay</span> <span class="nx">$42</span><span class="p">.</span><span class="mi">00</span><span class="o">&lt;</span><span class="sr">/button</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">button</span> <span class="nx">data</span><span class="o">-</span><span class="nx">testid</span><span class="o">=</span><span class="dl">"</span><span class="s2">checkout-pay-button</span><span class="dl">"</span> <span class="nx">disabled</span><span class="o">&gt;</span><span class="nx">Pay</span> <span class="nx">$42</span><span class="p">.</span><span class="mi">00</span><span class="o">&lt;</span><span class="sr">/button</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">button</span> <span class="nx">data</span><span class="o">-</span><span class="nx">testid</span><span class="o">=</span><span class="dl">"</span><span class="s2">cart-remove-button</span><span class="dl">"</span><span class="o">&gt;</span><span class="nx">Remove</span><span class="o">&lt;</span><span class="sr">/button</span><span class="err">&gt;
</span></code></pre></div></div>

<p>Then in your POM:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">readonly</span> <span class="nx">payButton</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">"</span><span class="s2">checkout-pay-button</span><span class="dl">"</span><span class="p">);</span>
<span class="k">readonly</span> <span class="nx">removeBtn</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">"</span><span class="s2">cart-remove-button</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>XPath becomes the <strong>fallback</strong> you reach for when the frontend team can’t or won’t add a <code class="language-plaintext highlighter-rouge">data-testid</code> (third-party widgets, embedded <code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code>s, legacy modals). §7’s recipes show those escape hatches. §9.5 shows the path-of-least-resistance for the 90%.</p>

<blockquote>
  <p>For the full setup pipeline — when AI finds your locators by looking at screenshots, when BiDi/CDP replaces WebDriver, when you shard parallel runs across CI stages — see the <a href="/techtalkwith-veeresh/devops/automation/ci-cd-pipelines-for-test-automation/">CI/CD Pipelines for Test Automation (Jun 2026)</a> and the <a href="/techtalkwith-veeresh/automation/tools/selenium-bidi-vs-playwright-cdp/">Selenium BiDi vs Playwright CDP deep dive (Jul 2026)</a> posts.</p>
</blockquote>

<hr />

<h2 id="10-self-healing-when-xpath-stops-behaving">10. Self-Healing: When XPath Stops Behaving</h2>

<p>Even the best XPath breaks eventually. Frontend rename. Class merge. Element moves into a modal. <strong>That’s normal — that’s the frontend doing its job.</strong></p>

<p>The 2026 answer is layered healing:</p>

<pre><code class="language-mermaid">flowchart TD
    START[📍 findElement failed] --&gt; L1{1️⃣ Relative XPath&lt;br/&gt;still valid?}
    L1 --&gt;|Yes| OK1[✅ Pass]
    L1 --&gt;|No| L2{2️⃣ ARIA semantic role&lt;br/&gt;accessible name match?}
    L2 --&gt;|Yes| OK2[🩹 Heal → semantic locator]
    L2 --&gt;|No| L3{3️⃣ BiDi DOM diff&lt;br/&gt;element moved?}
    L3 --&gt;|Yes| OK3[🩹 Heal → new path]
    L3 --&gt;|No| FAIL[❌ Manual triage]
    style OK1 fill:#34d399,color:#000
    style OK2 fill:#0ea5c7,color:#fff
    style OK3 fill:#0ea5c7,color:#fff
    style FAIL fill:#f87171,color:#fff
</code></pre>

<p>Layer 1 is your well-written XPath. Layer 2 is semantic healing by ARIA role. Layer 3 is BiDi/CDP DOM diff. <strong>The XPath you write today is the input to Layer 1 of tomorrow’s AI healer.</strong> It’s still worth writing well.</p>

<hr />

<h2 id="11-the-12-question-refresher">11. The 12-Question Refresher</h2>

<p>Before you bookmark this page, answer these in your head. If any stumps you, scroll back to the relevant section.</p>

<ol>
  <li>What’s the difference between <code class="language-plaintext highlighter-rouge">/</code> and <code class="language-plaintext highlighter-rouge">//</code>?</li>
  <li>When do you use <code class="language-plaintext highlighter-rouge">text()</code> vs <code class="language-plaintext highlighter-rouge">normalize-space()</code> vs <code class="language-plaintext highlighter-rouge">.</code>?</li>
  <li>Name three axes you’d reach for today and what they do.</li>
  <li>What’s the space-padded <code class="language-plaintext highlighter-rouge">concat</code> trick and why does it exist?</li>
  <li>Give the XPath for “the third <code class="language-plaintext highlighter-rouge">&lt;td&gt;</code> of the row containing the cell that says ‘Total’”.</li>
  <li>How do you check a button is enabled before clicking?</li>
  <li>Why does <code class="language-plaintext highlighter-rouge">contains(@class,'btn')</code> match too much?</li>
  <li>Can XPath cross an iframe boundary on its own?</li>
  <li>Why prefer <code class="language-plaintext highlighter-rouge">data-testid</code> over XPath when you can?</li>
  <li>When is relative XPath the right tool over CSS?</li>
  <li>How do you make self-healing fit on top of your XPath?</li>
  <li>Which functions make up the “10 you’ll actually use”?</li>
</ol>

<details>
<summary><strong>📋 Quick answers (no peeking first!)</strong></summary>

1. `/` is child (one level); `//` is descendant-or-self (any depth).
2. `text()` matches exact text node; `normalize-space()` collapses whitespace; `.` is the **whole** descendant text concatenated.
3. `child::` (down one), `descendant::` (down many), `parent::` or `..` (up one), `following-sibling::` (sideways right), `ancestor::` (up many).
4. To do exact CSS-class matching, you surround the class with spaces — like `.btn` in CSS — because `@class` is a space-separated list.
5. `//tr[td[normalize-space()='Total']]/td[3]`
6. `//button[normalize-space()='Pay' and not(@disabled)]`
7. Class names share substrings (`btn` ⊂ `btn-primary` ⊂ `button-group`).
8. No — switch frame context first, or use Playwright's `&gt;&gt;&gt;` shadow chain.
9. It's an explicit, frontend-stable string the dev team owns — your locator stops breaking on CSS refactors.
10. When you need backward navigation (parent/ancestor), text-based filtering, or DOM wildcards. CSS only goes forward.
11. Layer your healers — primary anchor → semantic role → DOM diff. Treat your XPath as the first layer of defense.
12. `text()`, `contains()`, `normalize-space()`, `starts-with()`, `translate(s, 'A..Z', 'a..z')`, `string-length()`, `not()`, `count()`, `position()/last()`, `substring()`.

&gt; Note: `lower-case()` and `upper-case()` are **XPath 2.0+** functions. They look tidier than `translate()` but they **silently fail** in WebDriver XPath 1.0 evaluation. Stick to `translate()` for browser-side XPath. Reserve `lower-case()` for XSLT/saxon pipelines where 2.0 is available.

</details>

<hr />

<h2 id="12-beyond-the-basics-complex-xpath-css-for-sdets">12. Beyond the Basics: Complex XPath &amp; CSS for SDETs</h2>

<p>Section 7 showed locators that work in the happy path. Real AUTs hand you <strong>SVG icons with namespace prefixes</strong>, <strong>dynamic tables that re-order on every refresh</strong>, <strong>components nested three iframes deep</strong>, and <strong>accessibility trees that diverge from the DOM</strong>. This section is precisely the patterns those scenarios demand.</p>

<h3 id="121-svg-namespace-handling--the-silent-killer-of-path">12.1 SVG namespace handling — the silent killer of <code class="language-plaintext highlighter-rouge">//path</code></h3>

<p>Inline SVG inside HTML is parsed as XML with <code class="language-plaintext highlighter-rouge">xmlns="http://www.w3.org/2000/svg"</code>. Element names you see in DevTools as <code class="language-plaintext highlighter-rouge">path</code>, <code class="language-plaintext highlighter-rouge">rect</code>, <code class="language-plaintext highlighter-rouge">circle</code> are <em>prefixed</em> in the DOM — a naive <code class="language-plaintext highlighter-rouge">//path</code> returns <strong>zero matches</strong>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;svg</span> <span class="na">viewBox=</span><span class="s">"0 0 24 24"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;path</span> <span class="na">d=</span><span class="s">"M..."</span> <span class="na">fill=</span><span class="s">"#0ea5c7"</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;circle</span> <span class="na">cx=</span><span class="s">"12"</span> <span class="na">cy=</span><span class="s">"12"</span> <span class="na">r=</span><span class="s">"10"</span> <span class="na">fill=</span><span class="s">"#fbbf24"</span><span class="nt">/&gt;</span>
<span class="nt">&lt;/svg&gt;</span>
</code></pre></div></div>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Portable</span><span class="o">:</span><span class="w"> </span><span class="nt">match</span><span class="w"> </span><span class="nt">by</span><span class="w"> </span><span class="nf">local-name</span><span class="w"> </span><span class="p">(</span><span class="nt">strips</span><span class="w"> </span><span class="nt">prefix</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'svg'</span><span class="p">]</span><span class="o">//*</span><span class="p">[</span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'path'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@fill</span><span class="o">=</span><span class="s">'#0ea5c7'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">Or</span><span class="w"> </span><span class="nt">full</span><span class="w"> </span><span class="nt">URI</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nf">local-name</span><span class="w"> </span><span class="p">(</span><span class="nt">rare</span><span class="o">,</span><span class="w"> </span><span class="nt">advanced</span><span class="p">)</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//*</span><span class="p">[</span><span class="nf">namespace-uri</span><span class="p">()</span><span class="o">=</span><span class="s">'http://www.w3.org/2000/svg'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">local-name</span><span class="p">()</span><span class="o">=</span><span class="s">'circle'</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — paths inside SVG via X&amp;amp;Y multi-engine</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//*[local-name()='svg']//*[local-name()='circle']</span><span class="dl">"</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="c1">// CSS works in Chrome/Firefox 2026+ for SVG attribute selectors</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">svg circle[fill='#fbbf24']</span><span class="dl">"</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<blockquote>
  <p><strong>CSS vs XPath for SVG:</strong> Chromium 105+ and Firefox 121+ honor CSS selectors on SVG attributes, but Safari and some test-runner versions still lag. Always keep the XPath <code class="language-plaintext highlighter-rouge">local-name()</code> form in your fallback kit.</p>
</blockquote>

<h3 id="122-computed-indices-without-li3">12.2 Computed indices without <code class="language-plaintext highlighter-rouge">li[3]</code></h3>

<p>Indexed XPath breaks every time someone inserts a row above. <strong>Computed indices</strong> count siblings — they survive inserts, deletes, and reorderings:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"the 3rd li"</span><span class="w"> </span><span class="err">—</span><span class="w"> </span><span class="nt">survives</span><span class="w"> </span><span class="nt">anything</span><span class="w"> </span><span class="nt">before</span><span class="w"> </span><span class="nt">it</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">count</span><span class="p">(</span><span class="k">preceding-sibling</span><span class="o">::</span><span class="nt">li</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">2</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"the last 3 items"</span><span class="w"> </span><span class="err">—</span><span class="w"> </span><span class="nt">works</span><span class="w"> </span><span class="nt">regardless</span><span class="w"> </span><span class="nt">of</span><span class="w"> </span><span class="nt">total</span><span class="w"> </span><span class="nt">count</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">count</span><span class="p">(</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">li</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="m">3</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"rows 6–10 of a paginated list"</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">ul</span><span class="o">/</span><span class="nt">li</span><span class="p">[</span><span class="nf">position</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="m">5</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">position</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="m">10</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"the row whose 2nd cell reads 'Open'"</span><span class="w"> </span><span class="err">—</span><span class="w"> </span><span class="nt">schema-stable</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nt">td</span><span class="p">[</span><span class="m">2</span><span class="p">][</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Open'</span><span class="p">]]</span><span class="o">/</span><span class="nt">td</span><span class="p">[</span><span class="m">3</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"every 2nd row, 1-indexed"</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">table</span><span class="o">//</span><span class="nt">tr</span><span class="p">[</span><span class="nf">position</span><span class="p">()</span><span class="w"> </span><span class="ow">mod</span><span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">1</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="s">"the cell that spans 3 columns, then its sibling"</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="nt">td</span><span class="p">[</span><span class="na">@colspan</span><span class="o">=</span><span class="m">3</span><span class="p">]</span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="nt">td</span><span class="p">[</span><span class="m">1</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h3 id="123-role--state-machine--aria-chains">12.3 Role + state machine + ARIA chains</h3>

<p>Modern frontends announce semantics through ARIA. Chain the predicate on <strong>role → state → content</strong> and your XPath becomes an accessibility-tree query:</p>

<div class="language-xpath highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">expanded</span><span class="w"> </span><span class="nt">tree</span><span class="w"> </span><span class="nt">item</span><span class="o">,</span><span class="w"> </span><span class="nt">not</span><span class="w"> </span><span class="nt">disabled</span><span class="o">,</span><span class="w"> </span><span class="nt">inside</span><span class="w"> </span><span class="nt">the</span><span class="w"> </span><span class="nt">nav</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'treeitem'</span><span class="w">
        </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-expanded</span><span class="o">=</span><span class="s">'true'</span><span class="w">
        </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@aria-disabled</span><span class="o">=</span><span class="s">'true'</span><span class="p">)]</span><span class="w">
    </span><span class="o">/</span><span class="k">following-sibling</span><span class="o">::</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'group'</span><span class="p">]</span><span class="w">
    </span><span class="o">//</span><span class="nt">a</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Settings'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">progress</span><span class="w"> </span><span class="nt">bar</span><span class="w"> </span><span class="nt">at</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">specific</span><span class="w"> </span><span class="nt">value</span><span class="o">,</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nt">a</span><span class="w"> </span><span class="nt">wizard</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'progressbar'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-valuenow</span><span class="o">=</span><span class="s">'3'</span><span class="p">]</span><span class="w">
    </span><span class="o">/</span><span class="k">ancestor</span><span class="o">::</span><span class="nt">section</span><span class="w">
    </span><span class="o">//</span><span class="nt">button</span><span class="p">[</span><span class="nf">normalize-space</span><span class="p">()</span><span class="o">=</span><span class="s">'Next'</span><span class="p">]</span><span class="w">

</span><span class="o">&lt;</span><span class="err">!</span><span class="o">--</span><span class="w"> </span><span class="nt">the</span><span class="w"> </span><span class="nt">visible</span><span class="w"> </span><span class="nt">tabpanel</span><span class="w"> </span><span class="nt">that</span><span class="w"> </span><span class="nt">contains</span><span class="w"> </span><span class="nt">an</span><span class="w"> </span><span class="nt">editable</span><span class="w"> </span><span class="nt">input</span><span class="w"> </span><span class="o">--&gt;</span><span class="w">
</span><span class="o">//</span><span class="ow">div</span><span class="p">[</span><span class="na">@role</span><span class="o">=</span><span class="s">'tabpanel'</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="na">@aria-hidden</span><span class="o">=</span><span class="s">'false'</span><span class="p">]</span><span class="w">
  </span><span class="o">//</span><span class="nt">input</span><span class="p">[</span><span class="nf">not</span><span class="p">(</span><span class="na">@readonly</span><span class="p">)</span><span class="w"> </span><span class="ow">and</span><span class="w"> </span><span class="nf">not</span><span class="p">(</span><span class="na">@disabled</span><span class="p">)]</span><span class="w">
</span></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — when ARIA chains express intent, prefer engine selectors</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByRole</span><span class="p">(</span><span class="dl">'</span><span class="s1">treeitem</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">expanded</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
    <span class="p">.</span><span class="nx">getByRole</span><span class="p">(</span><span class="dl">'</span><span class="s1">link</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Settings</span><span class="dl">'</span> <span class="p">})</span>
    <span class="p">.</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<p><strong>Rule:</strong> when your predicate list climbs to 4+ conditions, slide it into Playwright’s ARIA chain instead. For Selenium/Cypress, the ARIA-predicate-XPath is the only path.</p>

<h3 id="124-iframe--shadow-dom--pure-xpath-cant-pierce-either">12.4 iframe + shadow DOM — pure XPath can’t pierce either</h3>

<p>This trips up every first-time SDET. Three rules:</p>

<table>
  <thead>
    <tr>
      <th>Boundary</th>
      <th>Pure XPath pierces?</th>
      <th>What actually works</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code> (same-origin)</td>
      <td>❌</td>
      <td><code class="language-plaintext highlighter-rouge">driver.switchTo().frame(...)</code> (Selenium); <code class="language-plaintext highlighter-rouge">frameLocator</code> (Playwright); <code class="language-plaintext highlighter-rouge">cy.frame</code> plugin (Cypress)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code> (cross-origin)</td>
      <td>❌</td>
      <td>BiDi/CDP snapshot diff, or <code class="language-plaintext highlighter-rouge">cy.origin</code> (Cypress)</td>
    </tr>
    <tr>
      <td>Shadow DOM open root</td>
      <td>❌</td>
      <td>Playwright <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> chain; <code class="language-plaintext highlighter-rouge">shadowRoot.evaluate(...)</code> JS, or CSS piercing in Cypress</td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — iframe: frameLocator wraps the inner frame's DOM</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">frameLocator</span><span class="p">(</span><span class="dl">'</span><span class="s1">iframe[name="payment"]</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">"</span><span class="s2">//input[@name='card']</span><span class="dl">"</span><span class="p">).</span><span class="nx">fill</span><span class="p">(</span><span class="dl">'</span><span class="s1">4111111111111111</span><span class="dl">'</span><span class="p">);</span>

<span class="c1">// Playwright — shadow DOM: &gt;&gt;&gt; chains host CSS to inner XPath</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">my-payment-form &gt;&gt;&gt; //button:has-text("Pay")</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium — manual frame switch + XPath inside</span>
<span class="n">driver</span><span class="o">.</span><span class="na">switchTo</span><span class="o">().</span><span class="na">frame</span><span class="o">(</span><span class="s">"payment"</span><span class="o">);</span>
<span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">xpath</span><span class="o">(</span><span class="s">"//input[@name='card']"</span><span class="o">)).</span><span class="na">sendKeys</span><span class="o">(</span><span class="s">"4111111111111111"</span><span class="o">);</span>
<span class="n">driver</span><span class="o">.</span><span class="na">switchTo</span><span class="o">().</span><span class="na">defaultContent</span><span class="o">();</span>          <span class="c1">// always switch back</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Cypress — iframe via plugin + jQuery selector inside (XPath would need cypress-xpath)</span>
<span class="nx">cy</span><span class="p">.</span><span class="nx">frameLoaded</span><span class="p">({</span> <span class="na">url</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/payment</span><span class="dl">'</span> <span class="p">});</span>
<span class="nx">cy</span><span class="p">.</span><span class="nx">iframe</span><span class="p">({</span> <span class="na">url</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/payment</span><span class="dl">'</span> <span class="p">})</span>
    <span class="p">.</span><span class="nx">find</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[name='card']</span><span class="dl">"</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">type</span><span class="p">(</span><span class="dl">'</span><span class="s1">4111111111111111</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Pure XPath stops at both boundaries — that’s the spec, not a bug.</strong> Reach for the framework’s boundary-crossing API, never a “clever” XPath expression that quietly returns 0.</p>

<h3 id="125-modern-css--has-and-is--where">12.5 Modern CSS — <code class="language-plaintext highlighter-rouge">:has()</code> and <code class="language-plaintext highlighter-rouge">:is()</code> / <code class="language-plaintext highlighter-rouge">:where()</code></h3>

<p>The <code class="language-plaintext highlighter-rouge">:has()</code> parent selector is the biggest CSS change since <code class="language-plaintext highlighter-rouge">&gt;</code> and <code class="language-plaintext highlighter-rouge">~</code> (Chromium 105+, Firefox 121+, Safari 15.4+, supported in Playwright 1.40+ and Cypress 13+):</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.card</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.icon.danger</span><span class="o">)</span>              <span class="c">/* card containing a danger icon */</span>
<span class="nc">.form-row</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.error-msg</span><span class="nd">:visible</span><span class="o">)</span>    <span class="c">/* row currently showing an error */</span>
<span class="nt">li</span><span class="nd">:not</span><span class="o">(</span><span class="nd">:has</span><span class="o">(</span><span class="nt">label</span><span class="o">))</span>                  <span class="c">/* list item with no label */</span>
<span class="nt">form</span><span class="nd">:has</span><span class="o">(</span><span class="nt">input</span><span class="nd">:invalid</span><span class="o">)</span>              <span class="c">/* form containing any invalid input */</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — :has() in locator() + filter({ has })</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">.card:has(.icon.danger)</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">has</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">a</span><span class="dl">'</span><span class="p">)</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>

<span class="c1">// And the inverse</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">hasNot</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">label</span><span class="dl">'</span><span class="p">)</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Selenium 4 — :has() once the underlying engine supports it</span>
<span class="n">driver</span><span class="o">.</span><span class="na">findElement</span><span class="o">(</span><span class="nc">By</span><span class="o">.</span><span class="na">cssSelector</span><span class="o">(</span><span class="s">".card:has(.icon.danger)"</span><span class="o">));</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">is()</code> and <code class="language-plaintext highlighter-rouge">where()</code> collapse comma-separated OR groups:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.title</span><span class="nd">:is</span><span class="o">(</span><span class="nt">h1</span><span class="o">,</span> <span class="nt">h2</span><span class="o">,</span> <span class="nt">h3</span><span class="o">,</span> <span class="nt">h4</span><span class="o">)</span>            <span class="c">/* keeps specificity of the most-specific branch */</span>
<span class="nc">.title</span><span class="nd">:where</span><span class="o">(</span><span class="nt">h1</span><span class="o">,</span> <span class="nt">h2</span><span class="o">,</span> <span class="nt">h3</span><span class="o">,</span> <span class="nt">h4</span><span class="o">)</span>         <span class="c">/* specificity 0 (overridable) */</span>
<span class="nt">button</span><span class="nd">:is</span><span class="o">([</span><span class="nt">type</span><span class="o">=</span><span class="s2">'submit'</span><span class="o">],</span> <span class="nc">.btn-primary</span><span class="o">,</span> <span class="o">[</span><span class="nt">aria-label</span><span class="o">*=</span><span class="s2">'Pay'</span> <span class="nt">i</span><span class="o">])</span>   <span class="c">/* OR with type + class + ARIA */</span>
</code></pre></div></div>

<h3 id="126-advanced-css--nth-child-formulas--sibling-combinators">12.6 Advanced CSS — <code class="language-plaintext highlighter-rouge">:nth-child()</code> formulas + sibling combinators</h3>

<p><code class="language-plaintext highlighter-rouge">nth-child(an+b)</code> is a tiny DSL you keep reusing:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">tr</span><span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="nt">n</span><span class="o">)</span>                     <span class="c">/* every 2nd row (even rows) */</span>
<span class="nt">tr</span><span class="nd">:nth-child</span><span class="o">(</span><span class="err">2</span><span class="nt">n</span><span class="o">+</span><span class="err">1</span><span class="o">)</span>                   <span class="c">/* every 2nd row starting at row 1 (odd) */</span>
<span class="nt">li</span><span class="nd">:nth-child</span><span class="o">(</span><span class="nt">-n</span><span class="o">+</span><span class="err">5</span><span class="o">)</span>                   <span class="c">/* first 5 items */</span>
<span class="nt">td</span><span class="nd">:nth-last-child</span><span class="o">(</span><span class="nt">-n</span><span class="o">+</span><span class="err">2</span><span class="o">)</span>              <span class="c">/* last 2 cells */</span>
<span class="nd">:nth-child</span><span class="o">(</span><span class="err">3</span><span class="nt">n</span><span class="o">+</span><span class="err">1</span><span class="o">)</span>                     <span class="c">/* every 3rd item, starting at position 1 */</span>
<span class="nt">tr</span><span class="nd">:only-child</span>                        <span class="c">/* row with no siblings */</span>
<span class="nt">td</span><span class="nd">:nth-of-type</span><span class="o">(</span><span class="err">4</span><span class="o">)</span>                    <span class="c">/* 4th td by element type (ignores other tag kinds) */</span>
</code></pre></div></div>

<p>Sibling combinators handle adjacency and range — they shine in <strong>form validation</strong> and <strong>tab spacing</strong>:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">button</span> <span class="o">+</span> <span class="nt">button</span>                      <span class="c">/* button immediately after another button */</span>
<span class="nt">input</span><span class="nd">:invalid</span> <span class="o">~</span> <span class="nc">.error-icon</span>          <span class="c">/* any error-icon sibling after an invalid input */</span>
<span class="nt">input</span><span class="nd">:invalid</span> <span class="o">~</span> <span class="nc">.error-icon</span><span class="nd">:first-of-type</span>   <span class="c">/* first error icon only */</span>
<span class="nt">form</span> <span class="nt">label</span><span class="nd">:first-of-type</span> <span class="o">~</span> <span class="nt">input</span><span class="nd">:not</span><span class="o">([</span><span class="nt">type</span><span class="o">=</span><span class="s2">'hidden'</span><span class="o">])</span><span class="nd">:first-of-type</span>   <span class="c">/* first visible input after the first label */</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — ;nth-child() works inside locator() strings</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr:nth-child(2n+1) td:nth-child(3)</span><span class="dl">'</span><span class="p">).</span><span class="nx">allInnerTexts</span><span class="p">();</span>

<span class="c1">// Or use the locator API for clarity</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr</span><span class="dl">'</span><span class="p">).</span><span class="nx">nth</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">tr:nth-child(2n)</span><span class="dl">'</span><span class="p">).</span><span class="nx">filter</span><span class="p">({</span> <span class="na">hasText</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Total</span><span class="dl">'</span> <span class="p">}).</span><span class="nx">count</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="127-case-insensitive-attribute-flags--state-pseudos">12.7 Case-insensitive attribute flags + state pseudos</h3>

<p>The 2026 CSS spec adds the <code class="language-plaintext highlighter-rouge">i</code> flag to attribute selectors — your case-insensitive matcher no longer needs <code class="language-plaintext highlighter-rouge">translate()</code> gymnastics in CSS:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">a</span><span class="o">[</span><span class="nt">href</span><span class="o">*=</span><span class="s1">"github"</span> <span class="nt">i</span><span class="o">]</span>                  <span class="c">/* matches "GitHub", "GITHUB", "github" */</span>
<span class="nt">input</span><span class="o">[</span><span class="nt">name</span><span class="o">*=</span><span class="s1">"email"</span> <span class="nt">i</span><span class="o">]</span>               <span class="c">/* match in any casing */</span>
<span class="o">[</span><span class="nt">type</span><span class="o">=</span><span class="s1">"checkbox"</span> <span class="nt">i</span><span class="o">]</span>                  <span class="c">/* exact match, insensitive */</span>
<span class="o">[</span><span class="nt">title</span><span class="o">*=</span><span class="s1">"Pay"</span> <span class="nt">i</span><span class="o">]</span>                     <span class="c">/* anywhere in attr value */</span>
</code></pre></div></div>

<p>State pseudos let CSS <em>behave</em> like assertion logic — perfect for SDET locators that depend on UI state:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">input</span><span class="nd">:placeholder-shown</span>              <span class="c">/* empty input currently showing the placeholder */</span>
<span class="nt">form</span><span class="nd">:focus-within</span>                    <span class="c">/* form containing the focused field */</span>
<span class="nt">button</span><span class="nd">:not</span><span class="o">(</span><span class="nd">:disabled</span><span class="o">)</span><span class="nd">:hover</span>          <span class="c">/* interactive; flaky under CI animation frames */</span>
<span class="nt">li</span><span class="nd">:empty</span>                             <span class="c">/* &lt;li&gt;&lt;/li&gt; with no children at all */</span>
<span class="nt">input</span><span class="nd">:placeholder-shown</span> <span class="o">~</span> <span class="nt">label</span>      <span class="c">/* label next to an empty input */</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — assert by computed class instead of pseudo</span>
<span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">.checkout-form</span><span class="dl">'</span><span class="p">)).</span><span class="nx">toHaveClass</span><span class="p">(</span><span class="sr">/focus-within/</span><span class="p">);</span>

<span class="c1">// Or: focus an input and assert the parent form contains the focused class</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">input[name="card"]</span><span class="dl">'</span><span class="p">).</span><span class="nx">focus</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">form</span><span class="dl">'</span><span class="p">)).</span><span class="nx">toHaveClass</span><span class="p">(</span><span class="sr">/has-focus/</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="128-the-xpath-vs-css-vs-playwright-decision-flowchart">12.8 The XPath vs CSS vs Playwright decision flowchart</h3>

<pre><code class="language-mermaid">flowchart TD
    S["Start:&lt;br/&gt;what do you need?"] --&gt; Q1{"Need text match?"}
    Q1 --&gt;|"Yes"| XP{"Traverse backward&lt;br/&gt;or ancestor?"}
    Q1 --&gt;|"No"| Q2{"Parent selector needed?&lt;br/&gt;X:has Y"}
    Q2 --&gt;|"Yes"| CSSH["CSS :has() or XPath axis"]
    Q2 --&gt;|"No"| Q3{"Need SVG&lt;br/&gt;or xmlns?"}
    Q3 --&gt;|"Yes"| XPS["XPath local-name()&lt;br/&gt;or CSS svg[...]"]
    Q3 --&gt;|"No"| Q4{"Speed + simplicity&lt;br/&gt;is paramount?"}
    Q4 --&gt;|"Yes"| CSSQ["CSS selector:&lt;br/&gt;short and fast"]
    Q4 --&gt;|"No"| Q5{"Need nth-child&lt;br/&gt;or sibling range?"}
    Q5 --&gt;|"Yes"| CSSN["CSS :nth-child&lt;br/&gt;or sibling combinators"]
    Q5 --&gt;|"No"| PW["Playwright engine:&lt;br/&gt;role=, text=, near="]
    XP --&gt;|"Yes"| XPA["XPath ancestor::&lt;br/&gt;following-sibling::"]
    XP --&gt;|"No"| XPR["XPath predicate chain"]
    style XP fill:#fbbf24,color:#000
    style XPA fill:#fbbf24,color:#000
    style XPR fill:#fbbf24,color:#000
    style XPS fill:#fbbf24,color:#000
    style CSSH fill:#34d399,color:#000
    style CSSQ fill:#34d399,color:#000
    style CSSN fill:#34d399,color:#000
    style PW fill:#a78bfa,color:#000
</code></pre>

<h3 id="129-where-playwrights-engine-selectors-beat-both-xpath--css">12.9 Where Playwright’s engine selectors beat both XPath &amp; CSS</h3>

<p>Playwright’s selector engine adds a third category — engine-specific selectors that aren’t strictly CSS or XPath:</p>

<table>
  <thead>
    <tr>
      <th>Selector</th>
      <th>What it does</th>
      <th>When to reach for it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">text="Sign in"</code></td>
      <td>Match visible text node</td>
      <td>Faster + more readable than <code class="language-plaintext highlighter-rouge">//*[text()=...]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">role=button[name="Pay"]</code></td>
      <td>ARIA role + accessible name</td>
      <td>Cleanest accessibility-first locator</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">internal:text="Pay"</code></td>
      <td>Strict multi-element text across shadow DOM</td>
      <td>Shadow-rooted text</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:near(button:has-text("Total"))</code></td>
      <td>Visual proximity CSS pseudo-class</td>
      <td>Date pickers, dense tables with many “Total” cells</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code></td>
      <td>Shadow-DOM piercing</td>
      <td>Components with nested shadow roots</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">nth=0</code>, <code class="language-plaintext highlighter-rouge">nth=2</code></td>
      <td>Zero-indexed positional</td>
      <td>Easier to reason about than <code class="language-plaintext highlighter-rouge">:nth-child(2n+1)</code></td>
    </tr>
  </tbody>
</table>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Playwright — when engine selectors express intent best</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">getByRole</span><span class="p">(</span><span class="dl">'</span><span class="s1">button</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Pay $42.00</span><span class="dl">'</span><span class="p">,</span> <span class="na">exact</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">text="Sign in"</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">button:near(:text("Total"))</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="dl">'</span><span class="s1">my-app &gt;&gt;&gt; //button:has-text("Pay")</span><span class="dl">'</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>   <span class="c1">// shadow+text</span>
</code></pre></div></div>

<p>For Selenium/Cypress projects (no Playwright engine), fall back to the XPath/CSS patterns from §12.5/§12.6. For multi-engine projects, <strong>learn all three categories</strong> so you can review generated locators confidently and reject the ones that won’t survive.</p>

<blockquote>
  <p>When AI agents generate these locators from screenshots, the output mixes all three. Knowing the <strong>boundaries between XPath, CSS, and Playwright engine selectors</strong> lets you triage generated selectors fast and keep only the ones that survive CI.</p>
</blockquote>

<hr />

<h2 id="where-the-cheatsheet-fits">Where the Cheatsheet Fits</h2>

<p>This article is the <strong>story mode</strong>. It contains interactive try-it-yourself boxes, diagrams, recipes against real test sites, and mistakes to avoid. Open it the first six times you face a tricky locator.</p>

<p>After that, you’ll want the <strong><a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">XPath Cheatsheet for Test Automation Engineers</a></strong> — dense, scannable, anchored TOC, code samples in five languages. It’s the pocket reference. The two posts are designed to live side by side in your bookmarks bar.</p>

<pre><code class="language-mermaid">flowchart LR
    POST[📖 This article&lt;br/&gt;story mode] --&gt;|graduates into| SHEET[📋 Cheatsheet&lt;br/&gt;pocket reference]
    POST --&gt;|graduates into| PLAY[🎭 Playwright locators&lt;br/&gt;(once you outgrow XPath)]
    SHEET --&gt; PLAY
    style POST fill:#0ea5c7,color:#fff
    style SHEET fill:#34d399,color:#000
    style PLAY fill:#a78bfa,color:#000
</code></pre>

<h2 id="sources--further-reading">Sources &amp; Further Reading</h2>

<ol>
  <li><a href="https://devhints.io/xpath">XPath — devhints.io</a> — the cheatsheet that inspired this article’s pocket-reference sibling</li>
  <li><a href="https://www.selenium.dev/documentation/webdriver/elements/locators/">Selenium locators — official docs</a> — the canonical WebDriver locator reference</li>
  <li><a href="https://playwright.dev/docs/locators">Playwright locators</a> — when you’re ready to graduate from XPath to semantic selectors</li>
  <li><a href="https://developer.chrome.com/docs/devtools/console/">Chrome DevTools Console API</a> — <code class="language-plaintext highlighter-rouge">$x(path)</code> returns a JS array of matched elements from the active document; <code class="language-plaintext highlighter-rouge">$x('.//button', node)</code> scopes to any subtree you pass in</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/XPath/Comparison_with_CSS_selectors">MDN — Comparison of CSS Selectors with XPath</a> — XPath spec explained alongside CSS selectors</li>
</ol>

<h2 id="what-to-do-next">What to Do Next</h2>

<ol>
  <li><strong>Run the Try-It-Yourself box</strong> in Section 1 right now. Open DevTools on this very page, run <code class="language-plaintext highlighter-rouge">$x("//h2")</code>, see the headings in your console. Cost: 30 seconds.</li>
  <li><strong>Bookmark the <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">companion cheatsheet</a></strong>. The cheatsheet is your desk reference; this article is your training wheel.</li>
  <li><strong>Pick 3 brittle locators</strong> in your current suite. Translate them into the space-padded <code class="language-plaintext highlighter-rouge">concat</code>/<code class="language-plaintext highlighter-rouge">normalize-space</code>/<code class="language-plaintext highlighter-rouge">following-sibling</code> patterns from Section 7. Run your suite. Watch the false-negatives drop.</li>
  <li><strong>Add a <code class="language-plaintext highlighter-rouge">data-testid</code> request</strong> to your dev team’s frontend story. Show them the Locator Priority Pyramid from Section 2 and the SDET ↔ Frontend contract from Section 9.5. They’ll thank you in three months.</li>
  <li><strong>Audit your POM placement</strong> — pull every inline <code class="language-plaintext highlighter-rouge">By.xpath(...)</code> literal out of your test specs into a page object as a <code class="language-plaintext highlighter-rouge">By</code>/<code class="language-plaintext highlighter-rouge">Locator</code> property. One rename should fix 200 specs.</li>
  <li><strong>Wire failure-side observability</strong> using the patterns from §9.5 — HTML + screenshot + the failed XPath in the report. Cost: ~30 min of config, saves weeks of triage later.</li>
  <li><strong>For the next level of stability</strong>, layer semantic healing on top using self-healing locators and fallback strategies.</li>
  <li><strong>For pipeline-side stability</strong> (parallel sharding, BiDi/CDP, screenshot-on-failure in CI), read <a href="/techtalkwith-veeresh/devops/automation/ci-cd-pipelines-for-test-automation/">CI/CD Pipelines for Test Automation (Jun 2026)</a>.</li>
</ol>

<p><strong>Related:</strong> <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-to-css-translation-appendix/">XPath ↔ CSS Translation Appendix</a> — XPath 1.0 to CSS 2/3/4 and Playwright locators. <a href="/techtalkwith-veeresh/automation/tools/reference/xpath-cheatsheet-for-test-automation/">XPath Cheatsheet</a> — pocket reference with code in 5 languages.</p>]]></content><author><name>Veeresh Bikkaneti</name></author><category term="automation" /><category term="best-practices" /><category term="tools" /><category term="xpath" /><category term="locators" /><category term="selenium" /><category term="playwright" /><category term="cypress" /><category term="css-selectors" /><category term="sdet" /><category term="ci-cd" /><category term="page-object-model" /><category term="complex-xpath" /><category term="complex-css" /><category term="svg" /><category term="shadow-dom" /><category term="iframe" /><category term="aria" /><category term="debug" /><category term="beginner" /><category term="intermediate" /><category term="advanced" /><category term="java" /><category term="python" /><category term="javascript" /><category term="typescript" /><category term="interactive" /><summary type="html"><![CDATA[Built for SDETs who automate with Selenium, Playwright, and Cypress. Mental models, the 13 axes, the 10 functions, 7 locator recipes, the 5 mistakes to avoid, multi-framework API mapping, **complex XPath (SVG, computed indices, ARIA chains, iframe/shadow DOM)**, **complex CSS (`:has()`, `:is()`/`:where()`, `:nth-child()`, attribute flags, sibling combinators)**, Page Object placement, headless/CI pitfalls, and observability hooks — all in one interactive read.]]></summary></entry></feed>