<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Denis Skvortsov</title>
    <description>The latest articles on DEV Community by Denis Skvortsov (@denis_skvortsov).</description>
    <link>https://dev.to/denis_skvortsov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3079093%2F4fd4f610-1231-4ddd-9960-c1c424b41151.jpg</url>
      <title>DEV Community: Denis Skvortsov</title>
      <link>https://dev.to/denis_skvortsov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denis_skvortsov"/>
    <language>en</language>
    <item>
      <title>17 PRs a Day, One QA: How We Automated E2E Failure Triage</title>
      <dc:creator>Denis Skvortsov</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:30:13 +0000</pubDate>
      <link>https://dev.to/denis_skvortsov/17-prs-a-day-one-qa-how-we-automated-e2e-failure-triage-260g</link>
      <guid>https://dev.to/denis_skvortsov/17-prs-a-day-one-qa-how-we-automated-e2e-failure-triage-260g</guid>
      <description>&lt;p&gt;Picture this: 18 AI-augmented developers merging an average of 17 pull requests every working day — 33 on the peak day. And at the time, exactly one QA engineer watching over all of it. The fact that things didn't fall apart daily looks like a miracle. Spoiler: it's not a miracle, it's e2e tests on staging — plus a bot that triages their failures so humans don't have to.&lt;/p&gt;

&lt;p&gt;I'm that QA engineer. Here's how we at &lt;a href="https://pdf.net/" rel="noopener noreferrer"&gt;pdf.net&lt;/a&gt; got a GitHub Action + Claude to triage every red run: the architecture, the code, and the rakes we stepped on — the bot blaming innocent people, losing tickets, and staying silent when silence was the worst option. Headline number up front: the whole thing costs less per month than one hour of engineering time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we got here
&lt;/h2&gt;

&lt;p&gt;Our team is AI-first by design: developers ship with AI assistants, and instead of scaling manual testing along with headcount, we bet on automation. Autotests aren't a safety net "just in case" — they're the primary trigger that we're doing something wrong.&lt;/p&gt;

&lt;p&gt;We release once a day, and originally the release was where everything got verified: fifteen-plus PRs roll up, tests run, and if something's red, the one QA engineer investigates. A single release could contain several broken PRs at once, each to be identified among 17+ suspects while the release waits. Mornings turned into detective work.&lt;/p&gt;

&lt;p&gt;So we shifted the loop left. Quick infra note: every PR gets a Vercel preview (a dev environment — running the full e2e scope there makes no sense: it's slow, and half the features sit behind flags that don't match production), while staging is effectively pre-prod: every merge lands there, and code ships to production from there. E2e now runs on every merge to staging — the suspect range becomes one fresh merge instead of a day's batch, and the release turns into a final "works assembled" check plus manual testing that automation can't cover.&lt;/p&gt;

&lt;p&gt;The price: red runs now happen potentially after every one of those 17 merges, each needs a quick investigation, and until it's done the release is in question. Hiring a person for "look at a failed run and decide who to hand it to" is overkill — that's not a role, it's a distraction. The reasoning wrote itself: if our tests are automation, triaging their failures should be automation too.&lt;/p&gt;

&lt;p&gt;Handily, the failure sources are a closed list — a PR regression in the main repo, the neighboring backend repo (its deploys trigger our e2e too), external services, CI infrastructure, or a flake. Manual triage was always the same mechanical chain over structured inputs: failed tests, diffs, logs → a three-way call. Exactly the kind of task LLMs are good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: verdict → routing
&lt;/h2&gt;

&lt;p&gt;The triage is a reusable composite GitHub Action, invoked after every e2e run — red and green. Around it: Allure TestOps (test results), Linear (tickets), Slack (notifications), Claude API (the brain).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      e2e run on staging
                             │
               red ──────────┴────────── green
                │                          │
           AI verdict                 Recovery: close tickets,
                │                     rebase-nudge PR authors
     ┌──────────┴───────────────┐
likely-pr-related      likely-not-pr-related
     │                 / insufficient-data
Linear ticket                   │
+ Slack thread         Slack heads-up + ONE auto-rerun
                                │
                     ┌──────────┴──────────┐
                rerun green            rerun red
                     │                      │
              it was a flake —      escalation: ticket
              note it, move on      + @oncall mention
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a red run the bot collects failed tests with steps from Allure, job logs, the deploy commit range, and the diffs of the PRs that went in — and asks Claude (Opus 4.8, Sonnet 5 fallback) for a verdict plus a fix hypothesis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askClaude&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;failedTests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobLogs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deployRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prDiffs&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; 'likely-pr-related' | 'likely-not-pr-related' | 'insufficient-data'&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;likely-pr-related&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;headsUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;// "doesn't look PR-related — rerunning"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;requestRerun&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;// exactly one auto-rerun&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// PR-related, or a red rerun → Linear ticket + Slack post,&lt;/span&gt;
&lt;span class="c1"&gt;// on a red rerun — with an on-call mention&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createLinearIssue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hypothesis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failedTests&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key decision: the verdict picks a route, it doesn't just write text. PR-related → ticket with a hypothesis and a Slack thread. Anything else → no ticket yet: a heads-up and one auto-rerun. Green rerun — it was a flake, no noise. Red rerun — ticket plus an on-call mention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 e2e failed on staging — 3 tests
Verdict: likely-pr-related
Hypothesis: regression in PR #1234 — the file upload handler
changed, tests fail on the "attach document" step
Ticket: QA-482 · Staging: 🔴 red — release on hold
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tickets land in one Linear queue, with labels telling frontend deploys from backend ones. One manual habit pays for it all: a confirmed real bug gets a &lt;code&gt;bug&lt;/code&gt; label — so "how many bugs did autotests catch, and how much was noise" is a plain queue filter.&lt;/p&gt;

&lt;p&gt;Noise control, four mechanisms: dedup (repeat failures match open auto-tickets instead of spawning new ones), auto-close (green run closes the ticket, posts recovery with outage duration, and nudges open PRs to rebase), two channels (ticket-backed posts go to the important channel, rerun chatter stays in the working one), and manual muting (tickets closed without a fix go to Canceled, not Done — recovery leaves them alone).&lt;/p&gt;

&lt;p&gt;Neat on paper. Now the four rakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rake #1: the ticket stuck open forever
&lt;/h2&gt;

&lt;p&gt;Recovery writes to three places: a Slack post, the Done transition, and a &lt;code&gt;recovered-at: &amp;lt;sha&amp;gt;&lt;/code&gt; marker comment so the next green run skips the ticket. The first version wrote the marker first. Slack post succeeds, Done transition fails on a network hiccup — the marker is already there, and every following run says "already processed". Forever.&lt;/p&gt;

&lt;p&gt;The fix is a classic idempotent sequence — each step guarded by its own marker, "fully processed" written last:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasSlackPostedComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildRecovery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cause&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`slack-recovery-posted:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stateType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transitionToDone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasRecoveredAtComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`recovered-at:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sha7&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// strictly last&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail in the middle — the next run completes the rest without duplicating anything. Bonus: state lives in the ticket's own comments — no external database, human-readable, garbage-collected with the ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rake #2: the bot that blamed the innocent
&lt;/h2&gt;

&lt;p&gt;Recovery posts ended with "thanks for the fix, @author" — author computed from the attached fix-PR, or, as a fallback, from the commits in the green run's deploy range. But when a ticket closed because an external service came back to life, the fallback still found an "author": some random person whose innocent PR rode the same deploy. Publicly thanked for a fix that never existed. A couple of those and trust in the bot is gone.&lt;/p&gt;

&lt;p&gt;The fix is a recovery-cause classifier — a pure function, no AI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RecoveryCause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code-fix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;external&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ambiguous&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;classifyFixCause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;deployRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;originalVerdict&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;RecoveryCause&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deployRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isCodeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;codeFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;external&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;       &lt;span class="c1"&gt;// only docs/lockfiles deployed&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalVerdict&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;likely-not-pr-related&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ambiguous&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code-fix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attribution now fires only for &lt;code&gt;code-fix&lt;/code&gt;; &lt;code&gt;external&lt;/code&gt; and &lt;code&gt;ambiguous&lt;/code&gt; use a no-names template. &lt;code&gt;ambiguous&lt;/code&gt; doubles as telemetry: how often the model's verdict diverged from the actual cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rake #3: mixed runs and the false "rerun helped"
&lt;/h2&gt;

&lt;p&gt;The first dispatcher was &lt;code&gt;if (hasFailures) runAnalysis() else runRecovery()&lt;/code&gt;. Breaks on a mixed run: old ticket's tests are fixed, but something new failed — the run is red, recovery never fires, the fixed ticket hangs open. The fix: both paths run on every invocation, recovery first, each no-oping when idle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runRecovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// close everything that recovered&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// triage everything that failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immediate blast radius: our "rerun helped" signal was just &lt;code&gt;runAttempt &amp;gt;= 2&lt;/code&gt;, which was safe while recovery ran only on green runs. Once it ran always, the bot cheerfully posted "✅ recovered — auto-rerun helped" on a red rerun. Hence the explicit guard: &lt;code&gt;runAttempt &amp;gt;= 2 &amp;amp;&amp;amp; failureSet.size === 0&lt;/code&gt;. Every check that implicitly relied on the old call order has to become explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rake #4: the run triggered by someone else's deploy
&lt;/h2&gt;

&lt;p&gt;Our e2e lives in the frontend repo and walks the full user scenario through the UI — so it catches backend breakage too, and the backend's staging deploy triggers the same workflow via &lt;code&gt;workflow_dispatch&lt;/code&gt;. The GitHub Actions quirk you learn the hard way: a dispatched run has no push context. &lt;code&gt;GITHUB_EVENT_BEFORE&lt;/code&gt; is empty, there is no deploy range — and the bot died silently: no ticket, no post. Red staging, and silence. The worst failure mode for a triage system: everyone assumes quiet bot = all under control.&lt;/p&gt;

&lt;p&gt;The fix — the deploy range became an explicit input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;our-org/staging-triage@v1.6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;deploy-repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;our-org/backend&lt;/span&gt;        &lt;span class="c1"&gt;# whose deploy triggered the e2e&lt;/span&gt;
    &lt;span class="na"&gt;deploy-before-sha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.before-sha }}&lt;/span&gt;
    &lt;span class="na"&gt;deploy-head-sha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.head-sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything downstream — diffs for the verdict, authors for attribution and mentions — now comes from the repo that actually deployed. One triage, both repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we tested it
&lt;/h2&gt;

&lt;p&gt;You can't unit-test a system whose behavior lives across CI, Linear, Slack, and Allure. Two things worked: a smoke PR walked commit by commit through the whole state series — first failure (ticket created?), repeat failure (dedup quiet?), mixed run (both paths fire?), green run (closed + recovery post?) — real runs, real APIs, no mocks. And a kill switch: manual &lt;code&gt;workflow_dispatch&lt;/code&gt; is suppressed unless &lt;code&gt;STAGING_TRIAGE_SIMULATED=true&lt;/code&gt;, so you can poke the workflow without spamming Linear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bot can't do
&lt;/h2&gt;

&lt;p&gt;The most dangerous failure mode of an LLM classifier isn't a wrong verdict — it's confident text about nothing: "possibly a flake", "check the environment", "probably a cold start". So the prompt is built on bans, not wishes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verdict first, justified in one sentence with a concrete signal (a diff file, a trace quote, a locator). The hypothesis format is keyed to the verdict: &lt;code&gt;likely-pr-related&lt;/code&gt; → file:line and what to change; &lt;code&gt;likely-not-pr-related&lt;/code&gt; → facts with quotes, what's missing, where a human should look.&lt;/li&gt;
&lt;li&gt;Hard bans: no files or locators not present in the provided context; no generic phrasing without a quote; and &lt;code&gt;insufficient-data&lt;/code&gt; is declared a correct, useful answer — otherwise the model invents hypotheses for the sake of pretty text.&lt;/li&gt;
&lt;li&gt;Budgeted context: traces, logs, commit range, full PR diffs and affected files under a 1.5 MB budget; on overflow files drop first, diffs trim from the tail, and a truncation note tells the model not to conclude from missing data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What still happens: hypotheses right about the area, wrong in the details — the first critical bug the bot caught, it pointed at the right place, but a human wrote the actual fix, just faster. That's fine. The bot doesn't replace an engineer — it replaces the first 15–30 minutes of their work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers and gut feel
&lt;/h2&gt;

&lt;p&gt;Honest part first: we haven't collected rigorous metrics, and two months is a small sample. But releases got noticeably faster — bugs are localized when the code lands on staging, not on release morning, and the 17-suspects detective game is gone. The &lt;code&gt;bug&lt;/code&gt; label groundwork means in six months there'll be real numbers.&lt;/p&gt;

&lt;p&gt;Cost: one model call per red run (recovery needs no AI), typically 30–80K input tokens — $0.3–0.5 per red run at Opus 4.8 prices, ~$2.5 worst case, $30–70 a month total. The QA team has grown since — I went from the only QA to leading it — but triage stayed with the bot: people do automation and agent experiments, not red-run duty. The automation was born from a shortage of hands and stayed because it's simply better.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But off-the-shelf tools exist!"
&lt;/h2&gt;

&lt;p&gt;They do. &lt;a href="https://cognition.com/" rel="noopener noreferrer"&gt;Cognition&lt;/a&gt; sells &lt;a href="https://devin.ai/auto-triage" rel="noopener noreferrer"&gt;Devin Auto-Triage&lt;/a&gt;; GitHub shipped a &lt;a href="https://github.blog/changelog/2026-05-18-one-click-fixes-for-failing-actions-with-copilot-cloud-agent/" rel="noopener noreferrer"&gt;“Fix with Copilot”&lt;/a&gt; button on failed workflow runs. We saw all that and deliberately built our own — because our task isn't "fix the red CI". It's a decision about the fate of a release: classify the cause, pick a route (ticket / rerun / escalation), dedup, auto-close, wake nobody without a reason — welded to our exact stack (Allure TestOps, Linear, Slack, two repos) and our processes.&lt;/p&gt;

&lt;p&gt;And the price tag: for an AI-first team this is days, not weeks — the code is almost entirely written with Claude Code. The honest division of labor: the human part is the prototype and the architecture — verdict separated from routing, idempotent recovery steps, pure-function classifiers, state in ticket comments instead of a database. AI writes great code inside a given structure and speeds iteration up by an order of magnitude; the structure that survives six releases in two months is set by a human.&lt;/p&gt;

&lt;p&gt;Why not open-source it, then? Similar "AI triages your failed CI" actions sit at 3–11 GitHub stars, and ours needs the exact Linear + Slack + Allure TestOps + Anthropic combination — that audience is vanishingly small, while baseline "AI explains the failure" is arriving in GitHub out of the box. What's worth open-sourcing here are the ideas, not the code — which is exactly what this article is.&lt;/p&gt;

&lt;p&gt;AI-first means AI-first.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>devops</category>
      <category>github</category>
    </item>
    <item>
      <title>Selective test execution mechanism with Playwright using GitHub Actions</title>
      <dc:creator>Denis Skvortsov</dc:creator>
      <pubDate>Sat, 24 May 2025 09:49:04 +0000</pubDate>
      <link>https://dev.to/denis_skvortsov/selective-test-execution-mechanism-with-playwright-using-github-actions-862</link>
      <guid>https://dev.to/denis_skvortsov/selective-test-execution-mechanism-with-playwright-using-github-actions-862</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Selective test execution:&lt;/strong&gt; run only the tests related to the actual code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saves time and resources:&lt;/strong&gt; speeds up the process and reduces CI/CD load, especially in cloud environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works for both monorepos and split repositories:&lt;/strong&gt; the solution fits projects with either a monorepo or separate frontend/backend repositories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uses GitHub Actions and Playwright:&lt;/strong&gt; configures CI/CD to filter tests by tags and run only the relevant ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example implementation:&lt;/strong&gt; available in a public GitHub &lt;a href="https://github.com/skvortsov-den/playwright-selective-tests-github-actions" rel="noopener noreferrer"&gt;repository&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When I first faced the task of setting up testing, I realized that running the entire test suite after every code change is not just slow - it's wasteful. Especially in large projects where microservices, frontend, and backend each have their own test sets. In such setups, running all tests for every commit or PR is unnecessary and significantly slows down the development cycle.&lt;/p&gt;

&lt;p&gt;For example, if a frontend developer changes a button or a small UI component, why would we run all backend tests? Or if a backend developer updates a single endpoint, there’s no point in triggering all UI tests - they’re completely unrelated. In those cases, tests become unnecessary overhead that slows down progress.&lt;/p&gt;

&lt;p&gt;In some teams, test success is a hard requirement before merging code. If you can’t merge until all tests pass - but your change affects only a small part of the system - triggering all tests becomes a bottleneck.&lt;/p&gt;

&lt;p&gt;To solve this, I implemented selective test execution - running only the tests that are actually affected by code changes. This approach helps save both time and infrastructure resources, making the testing and release process faster. In this article, I’ll share my experience and show how to set up such a mechanism using Playwright and GitHub Actions - whether you’re working in a monorepo or with separate frontend/backend repositories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problem are we solving?
&lt;/h2&gt;

&lt;p&gt;Running the full e2e test suite on every code change is not always justified. In projects that include frontend, backend and shared modules, this often leads to problems like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developers waiting for CI feedback longer than it takes to write the fix itself&lt;/li&gt;
&lt;li&gt;CI infrastructure usage skyrockets (and if you’re in the cloud - so do the costs)&lt;/li&gt;
&lt;li&gt;Tests are triggered that have nothing to do with the actual changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Typical scenarios:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A frontend developer changes a visual component, but all backend test chains are executed&lt;/li&gt;
&lt;li&gt;A backend developer tweaks business logic, and the entire UI test suite runs too&lt;/li&gt;
&lt;li&gt;A shared utility is updated, and suddenly no one knows whether to run a full regression or not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It gets even worse when tests are a mandatory check before merging. Even a minor change can block the entire process - while dozens or even hundreds of unrelated tests are waiting to complete.&lt;/p&gt;

&lt;p&gt;My goal wasn’t just to speed up testing. I wanted CI to run only what truly matters. Automatically. Without manual rules or exception lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  How i implemented it
&lt;/h2&gt;

&lt;p&gt;To demonstrate how you can run only the relevant e2e tests, I put together a small monorepository with a simple but flexible architecture. This isn’t a production-ready setup - it’s a demo project, intentionally simplified to make the mechanism easy to understand and adapt to any real-world structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My main goals were:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Everything should work transparently - no magic config files&lt;/li&gt;
&lt;li&gt;The implementation should be reusable - something you can easily apply to another project&lt;/li&gt;
&lt;li&gt;The structure should remain flexible - easy to extend with new services without rewriting the pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Project structure:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── frontend/                    # Frontend applications
│   └── apps/                    # Frontend microservices
│       ├── microservice1/
│       └── microservice2/
│       └── shared/             # Common frontend components

├── backend/                     # Backend services
│   └── apps/                    # Backend microservices
│       ├── microservice3/
│       ├── microservice4/
│       └── microservice5/

├── .github/                     # GitHub Actions
│   ├── workflows/               # CI/CD configuration
│   │   └── e2e-runner.yml       # Selective test runner
│   └── preconditions/           # Reusable actions
│       └── e2e/                 # E2E tests environment setup
│           └── action.yml       # Composite action for environment setup

└── tests/                       # Tests
    └── e2e/                     # E2E tests 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is split into logical areas - just like in real projects. There's frontend, backend, a shared area and a dedicated folder for e2e tests. The entire pipeline runs on GitHub Actions. Dependencies are installed using a reusable &lt;code&gt;precondition&lt;/code&gt; action. The core logic is tag-based.&lt;/p&gt;

&lt;p&gt;The tests themselves are intentionally primitive - because this article isn’t about test coverage or scenarios, it’s about the mechanism for &lt;strong&gt;running only what matters&lt;/strong&gt;. Everything else is just context.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Tagging Tests
&lt;/h3&gt;

&lt;p&gt;To determine which tests should run, I use tags directly in the test definitions. The logic is simple: if a PR changes &lt;code&gt;apps/microservice3&lt;/code&gt;, the CI system looks for e2e tests tagged with &lt;code&gt;@apps/microservice3&lt;/code&gt; and runs only those.&lt;/p&gt;

&lt;p&gt;Each tag is tied to a specific microservice or module. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test 3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@apps/microservice3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAppsMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertServiceVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Maps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertServiceVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Gmail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;microservice3&lt;/code&gt; is affected, this test will be included. If another service is changed, it will be skipped-even if it's in the same file.&lt;/p&gt;

&lt;p&gt;I chose the format &lt;code&gt;@apps/&amp;lt;service-name&amp;gt;&lt;/code&gt; for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It matches the actual project structure.&lt;/li&gt;
&lt;li&gt;It’s easy to extract from file paths using &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Another example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test 1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@apps/microservice1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAppsMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertServiceVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YouTube&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertServiceVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YouTube Music&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a test is &lt;strong&gt;not tagged&lt;/strong&gt;, it won’t be picked up during selective test runs. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test 5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAppsMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertServiceVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Calendar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test will only run during a full test execution (for example, when &lt;code&gt;shared&lt;/code&gt; or &lt;code&gt;tests/e2e&lt;/code&gt; changes are detected).&lt;/p&gt;

&lt;p&gt;Tagging is a core part of this mechanism. It doesn’t require additional tooling and can be maintained manually if needed.&lt;/p&gt;

&lt;p&gt;Playwright docs on tags: &lt;a href="https://playwright.dev/docs/test-annotations" rel="noopener noreferrer"&gt;Test annotations&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Analyzing сhanges and mapping to tags
&lt;/h3&gt;

&lt;p&gt;The next step is to determine what exactly has changed in the PR and which tests should be triggered. This is done in GitHub Actions using a simple &lt;code&gt;git diff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we look for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Changes in paths like &lt;code&gt;apps/microserviceX&lt;/code&gt; → means a specific service was affected&lt;/li&gt;
&lt;li&gt;Changes in &lt;code&gt;shared&lt;/code&gt; → means potentially all services are affected&lt;/li&gt;
&lt;li&gt;Changes in &lt;code&gt;tests/e2e&lt;/code&gt; → likely the tests themselves were modified, so the full suite should be executed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s a simplified example from the &lt;code&gt;Find changes&lt;/code&gt; step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;changed_apps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; origin/main HEAD | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"/apps/[^/]+/"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;changed_test_files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; origin/main HEAD | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^tests/e2e/"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we normalize the paths. For example, &lt;code&gt;frontend/apps/microservice1/pages/page.tsx&lt;/code&gt; becomes &lt;code&gt;apps/microservice1&lt;/code&gt;, which maps directly to the tag &lt;code&gt;@apps/microservice1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;full_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed_apps&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s#^(.*/apps/[^/]+)/.*#\1#'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&lt;/span&gt; &lt;span class="s2"&gt;"|"&lt;/span&gt; -&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;test_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$full_paths&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s#.*(apps/[^/]+)#\1#'&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&lt;/span&gt; &lt;span class="s2"&gt;"|"&lt;/span&gt; -&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Then what happens:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the changes include &lt;code&gt;shared&lt;/code&gt; or &lt;code&gt;tests/e2e&lt;/code&gt;, we skip filtering and run &lt;strong&gt;all tests&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If only specific services were changed, we convert the paths into tags, which are passed to Playwright via &lt;code&gt;--grep&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is saved into a GitHub Actions output variable called &lt;code&gt;test_scope&lt;/code&gt;, which is used in later steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"test_scope=&lt;/span&gt;&lt;span class="nv"&gt;$test_paths&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;test_scope&lt;/code&gt; ends up empty, it means either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No relevant changes were found&lt;/li&gt;
&lt;li&gt;Or no tests are tagged to match the changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In such cases, you can either skip test execution or fall back to running the full suite - depending on your project’s policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Running tests with &lt;code&gt;grep&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Once we’ve determined which parts of the code have changed and built a list of tags, the next step is simply passing those tags to Playwright. For that, we use the built-in &lt;code&gt;--grep&lt;/code&gt; flag, which filters the tests by tags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example run:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the PR affects both &lt;code&gt;apps/microservice1&lt;/code&gt; and &lt;code&gt;apps/microservice4&lt;/code&gt;, the resulting scope looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;test_scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;apps/microservice1|apps/microservice4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the tests are run like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx playwright &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; &lt;span class="s2"&gt;"@apps/microservice1|@apps/microservice4"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;|| true&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;grep&lt;/code&gt; might return no matches - for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The service is new and doesn’t have tests yet&lt;/li&gt;
&lt;li&gt;The change is minor and not yet covered&lt;/li&gt;
&lt;li&gt;Tests exist but lack proper tags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In these cases, we don’t want the CI to fail. It’s okay to skip test execution if no relevant tests were found. Failure should only happen when tests exist and they fail - not when there are simply no tests to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When do we run all tests?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the changes include any of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shared directory (frontend or backend)&lt;/li&gt;
&lt;li&gt;Files in &lt;code&gt;tests/e2e&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Or if &lt;code&gt;test_scope&lt;/code&gt; is completely empty (depending on your policy)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We simply skip filtering and run the full suite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.detect-changes.outputs.test_scope == ''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx playwright &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of this is wrapped inside a pipeline with two jobs: &lt;code&gt;selective-tests&lt;/code&gt; and &lt;code&gt;all-tests&lt;/code&gt;. Based on the content of &lt;code&gt;test_scope&lt;/code&gt;, only one of them runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we get in the end
&lt;/h3&gt;

&lt;p&gt;After implementing selective test execution on GitHub Actions, the benefits were immediately obvious - both in terms of human effort and infrastructure usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faster builds&lt;/strong&gt;&lt;br&gt;
CI no longer runs the entire test suite for every little change. If only one microservice is affected, only its e2e tests are triggered. As a result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tests run significantly faster&lt;/li&gt;
&lt;li&gt;You get feedback almost immediately&lt;/li&gt;
&lt;li&gt;Releases are no longer blocked by irrelevant checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Resource savings&lt;/strong&gt;&lt;br&gt;
If you're running tests in the cloud, CI/CD can get expensive - especially when all tests are triggered for every pull request. Selective execution helps save actual money by avoiding unnecessary resource usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easy to maintain&lt;/strong&gt;&lt;br&gt;
You don’t need to manually build complex test mappings. The entire system is driven by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A simple tag in each test (&lt;code&gt;@apps/xxx&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git diff&lt;/code&gt; and &lt;code&gt;bash&lt;/code&gt; (2 lines of code)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grep&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The approach is flexible and adaptable to any structure. You can filter by modules, features, user flows, folders, roles - whatever makes sense in your project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works out of the box&lt;/strong&gt;&lt;br&gt;
This mechanism performs well in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monorepos&lt;/li&gt;
&lt;li&gt;Projects with split frontend and backend&lt;/li&gt;
&lt;li&gt;Any project where e2e tests are a mandatory merge requirement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Want real-world scenarios?&lt;/strong&gt;&lt;br&gt;
You can explore common situations - shared modules, new services, test changes, combinations - in the &lt;a href="https://github.com/skvortsov-den/playwright-selective-tests-github-actions/blob/main/README.md" rel="noopener noreferrer"&gt;project’s README&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Example GitHub Actions workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;E2E Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;detect-changes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test_scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.scope.outputs.test_scope }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Find changes&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "🔄  Analyzing changes in PR..."&lt;/span&gt;
          &lt;span class="s"&gt;changed_apps=$(git diff --name-only origin/main HEAD | grep -E "/apps/[^/]+/" || true)&lt;/span&gt;
          &lt;span class="s"&gt;changed_test_files=$(git diff --name-only origin/main HEAD | grep -E "^tests/e2e/" || true)&lt;/span&gt;

          &lt;span class="s"&gt;echo "📦 Files changed:"&lt;/span&gt;
          &lt;span class="s"&gt;[ -z "$changed_apps" ] &amp;amp;&amp;amp; echo "  No changes in files" || echo "$changed_apps" | sed 's/^/  /'&lt;/span&gt;

          &lt;span class="s"&gt;echo "🧪 E2E tests:"&lt;/span&gt;
          &lt;span class="s"&gt;[ -z "$changed_test_files" ] &amp;amp;&amp;amp; echo "  No changes in e2e tests" || echo "$changed_test_files" | sed 's/^/  /'&lt;/span&gt;

          &lt;span class="s"&gt;echo "✨ Affected services:"&lt;/span&gt;
          &lt;span class="s"&gt;full_paths=$(echo "$changed_apps" | sed -E 's#^(.*/apps/[^/]+)/.*#\1#' | sort -u | paste -sd "|" -)&lt;/span&gt;
          &lt;span class="s"&gt;[ -z "$full_paths" ] &amp;amp;&amp;amp; echo " No changes in services" || echo "$full_paths" | tr '|' '\n' | sed 's/^/  /'&lt;/span&gt;

          &lt;span class="s"&gt;test_paths=$(echo "$full_paths" | tr '|' '\n' | sed -E 's#.*(apps/[^/]+)#\1#' | paste -sd "|" -)&lt;/span&gt;
          &lt;span class="s"&gt;test_files=$(echo "$changed_test_files" | paste -sd "|" -)&lt;/span&gt;

          &lt;span class="s"&gt;echo "test_paths=${test_paths}" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "changed_test=${test_files}" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check shared modules and modified e2e tests&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shared&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;test_paths="${{ steps.changes.outputs.test_paths }}"&lt;/span&gt;
          &lt;span class="s"&gt;changed_test="${{ steps.changes.outputs.changed_test }}"&lt;/span&gt;

          &lt;span class="s"&gt;echo "🔄 Checking shared modules and e2e tests:"&lt;/span&gt;

          &lt;span class="s"&gt;has_shared=$(echo "$test_paths" | tr '|' '\n' | grep -q "shared" &amp;amp;&amp;amp; echo "true" || echo "false")&lt;/span&gt;
          &lt;span class="s"&gt;has_e2e_changes=$([ ! -z "$changed_test" ] &amp;amp;&amp;amp; echo "true" || echo "false")&lt;/span&gt;

          &lt;span class="s"&gt;$has_shared &amp;amp;&amp;amp; echo "⚠️ Changes in shared modules detected" &amp;amp;&amp;amp; echo "$test_paths" | tr '|' '\n' | grep "shared" | sed 's/^/  /'&lt;/span&gt;
          &lt;span class="s"&gt;$has_e2e_changes &amp;amp;&amp;amp; echo "⚠️ Changes in e2e tests detected" &amp;amp;&amp;amp; echo "$changed_test" | tr '|' '\n' | sed 's/^/  /'&lt;/span&gt;

          &lt;span class="s"&gt;{ $has_shared || $has_e2e_changes; } &amp;amp;&amp;amp; test_paths="" || echo "✅ No changes in shared modules or e2e tests"&lt;/span&gt;

          &lt;span class="s"&gt;echo "test_paths=$test_paths" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set final scope&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scope&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;test_paths="${{ steps.shared.outputs.test_paths }}"&lt;/span&gt;
          &lt;span class="s"&gt;echo "🔄 Result:"&lt;/span&gt;
          &lt;span class="s"&gt;[ -z "$test_paths" ] &amp;amp;&amp;amp; echo " ✅ All tests will be run" || {&lt;/span&gt;
            &lt;span class="s"&gt;echo " ✅ Running tests for:"&lt;/span&gt;
            &lt;span class="s"&gt;echo "$test_paths" | tr '|' '\n' | sed 's/^/    /'&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;echo "test_scope=$test_paths" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

  &lt;span class="na"&gt;selective-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detect-changes&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.detect-changes.outputs.test_scope != ''&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/playwright:v1.52.0&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/preconditions/e2e&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run selective tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx playwright test --grep "${{ needs.detect-changes.outputs.test_scope }}" || &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;all-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detect-changes&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.detect-changes.outputs.test_scope == ''&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/playwright:v1.52.0&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/preconditions/e2e&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run all tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx playwright test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Can you run only the tests that were changed?
&lt;/h3&gt;

&lt;p&gt;This is a common question that usually comes up first:&lt;br&gt;
&lt;strong&gt;"If we already know which files have changed - why not run only the tests that were also modified?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first glance, it sounds reasonable. But in practice it’s not always a good idea.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I didn’t go down that path
&lt;/h3&gt;

&lt;p&gt;In my setup, I run &lt;strong&gt;all tests related to the changed components&lt;/strong&gt;, not just the &lt;code&gt;.spec.ts&lt;/code&gt; files that were edited in the PR. The reason is simple - in some projects, tests are not properly isolated.&lt;/p&gt;

&lt;p&gt;A typical example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one test modifies some data,&lt;/li&gt;
&lt;li&gt;another test relies on the state left behind by the first one,&lt;/li&gt;
&lt;li&gt;or there’s a shared &lt;code&gt;setUp&lt;/code&gt; that affects the behavior of all tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is an anti-pattern, of course, but it's still common - especially in older or fast-growing projects.&lt;/p&gt;

&lt;p&gt;If you run only the modified test files, you can easily end up with a green build - even though the logic is actually broken.&lt;br&gt;
That’s why I went with a safer approach:&lt;br&gt;
&lt;strong&gt;we filter by the affected areas, but within that area, we run all the tests - even if the test files themselves weren't changed.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What about &lt;code&gt;--only-changed&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Playwright does support a &lt;code&gt;--only-changed&lt;/code&gt; flag that runs only the &lt;code&gt;.spec.ts&lt;/code&gt; files which were changed.&lt;br&gt;
It can be helpful as a temporary solution or for small PRs.&lt;/p&gt;

&lt;p&gt;But it’s important to understand: this flag only works at the file level.&lt;br&gt;
It doesn’t track which modules or helpers were changed, nor does it understand which tests depend on them.&lt;/p&gt;

&lt;p&gt;So if you modify something like &lt;code&gt;auth.ts&lt;/code&gt;, which is used across all tests - &lt;code&gt;--only-changed&lt;/code&gt; won’t pick that up, because the test files themselves didn’t change.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you can do if you want to take it further
&lt;/h3&gt;

&lt;p&gt;If your tests are well-isolated and your project architecture is clean, it’s possible to run only those tests that are truly affected by a change.&lt;/p&gt;

&lt;p&gt;At the &lt;code&gt;git diff&lt;/code&gt; stage, you can track not only changes in app code, but also updates to shared modules or e2e utilities. To identify which tests depend on those changes, you can build a &lt;strong&gt;dependency graph&lt;/strong&gt; between source files and test files - using tools like &lt;code&gt;ts-morph&lt;/code&gt; or &lt;code&gt;dependency-cruiser&lt;/code&gt;. This graph reveals which &lt;code&gt;.spec.ts&lt;/code&gt; files import or transitively rely on the modified code.&lt;/p&gt;

&lt;p&gt;This approach works best when your test structure is modular and the dependency graph is accurate and regularly maintained. Without that, the risk of silently skipping important tests increases.&lt;/p&gt;

&lt;p&gt;That’s why in many cases, it's safer and more predictable to run all tests within the affected scope - even if only a small change was made.&lt;br&gt;
It keeps things simple and reduces the chance of hidden regressions in CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Selective execution of e2e tests isn’t a silver bullet - but it’s an effective way to reduce build time, lower CI load, and speed up the development cycle. It’s especially valuable in projects where tests are a required condition for merging, and every minute of CI time matters.&lt;/p&gt;

&lt;p&gt;All you need is to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tag your tests appropriately&lt;/li&gt;
&lt;li&gt;analyze changes in the PR&lt;/li&gt;
&lt;li&gt;and run tests using the &lt;code&gt;--grep&lt;/code&gt; filter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution is simple, flexible and reusable. You can apply it in monorepos, in projects with separate frontend and backend repos or even as a shared internal standard within your team.&lt;/p&gt;

&lt;p&gt;If you want to take this further - like running only the truly modified tests - that’s also possible if architecture makes it achievable.&lt;/p&gt;

&lt;p&gt;If you’ve solved a similar problem differently - I’d love to hear how you approached it. Feel free to share your solutions.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>playwright</category>
      <category>automation</category>
      <category>testing</category>
    </item>
    <item>
      <title>Practical use of Cursor and MCP Playwright in test automation</title>
      <dc:creator>Denis Skvortsov</dc:creator>
      <pubDate>Wed, 23 Apr 2025 11:12:02 +0000</pubDate>
      <link>https://dev.to/denis_skvortsov/practical-use-of-cursor-and-mcp-playwright-in-test-automation-ac4</link>
      <guid>https://dev.to/denis_skvortsov/practical-use-of-cursor-and-mcp-playwright-in-test-automation-ac4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;br&gt;
This article is not a documentation review. It's my personal experience working with the Cursor and Playwright MCP tools for frontend test automation (JavaScript/TypeScript). I want to share how these tools truly help in everyday work, especially when it comes to writing or improving automated tests.&lt;br&gt;
The topic of automation is actively developing, and, perhaps in the near future, we will be able to automate tests almost by sheer willpower. But for now, I'll share what I've already found. Many developers are actively using AI for code generation, such as GitHub Copilot, but for me, Cursor in combination with Playwright MCP provides much more context. While these are far from perfect tools-sometimes they fix unnecessary places or don't work exactly as expected-they are still a powerful addition to the process.&lt;br&gt;
In this article, I will show how to use Cursor and Playwright MCP in practice, with test examples and explanations of where and how these tools help, as well as situations where they are better avoided.&lt;br&gt;
Here are the key points I would like to highlight in this article that may be useful when working with Cursor and MCP Playwright:&lt;br&gt;
Cursor can automatically add data-testid or getByRole to the code using a screenshot of the screen and highlighting the desired area where it should be inserted. This is especially useful for beginner automation engineers.&lt;br&gt;
MCP Playwright is useful for labeled pages, where elements have accessible attributes (e.g., data-testid, aria-label, etc.).&lt;br&gt;
MCP Playwright is ideal for writing simple scripts that do not require complex preconditions and works well for basic E2E testing.&lt;br&gt;
If you're using Cursor and MCP Playwright together, the best solution is to organize a monorepository, where both the frontend and automated tests are located in one repository.&lt;/p&gt;

&lt;p&gt;Additionally, it's important to remember that while these tools speed up the process, they do not replace engineering work, especially for more complex scenarios and integration tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test Architecture: Basic principles&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before implementing MCP Playwright, it's important to revisit your testing pyramid and define what e2e tests mean in your context. The testing pyramid can vary for different teams and projects. E2E and integration tests can overlap, but MCP Playwright is useful only for simple scenarios.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbp2p23djoevsdo2eeweq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbp2p23djoevsdo2eeweq.png" alt="Image description" width="800" height="801"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My tests and examples will be based on the website google.com, and I will use TypeScript for writing the tests. Google provides good accessibility for element recognition through MCP Playwright, which allows easy creation of stable tests for basic operations, such as searching on the page. I will also use the test architecture from the article &lt;a href="https://www.linkedin.com/pulse/simple-effective-e2e-test-architecture-playwright-denis-skvortsov-hv5pf" rel="noopener noreferrer"&gt;Simple and Effective E2E Test Architecture for Playwright&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How MCP Playwright and Cursor fit different types of&amp;nbsp;tests:&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;E2E Tests:&lt;/em&gt; MCP Playwright is ideal for simple e2e tests where you need to check UI interactions, especially if the page is properly labeled. For example, checking a button or an input field. Here, MCP automatically generates code with the correct locators, making test writing easier. However, it's important to remember that MCP Playwright works best with simple scenarios.&lt;br&gt;
&lt;em&gt;Integration Tests:&lt;/em&gt; For integration tests where you need to interact with both the UI and the API, traditional automation methods are better. Cursor can help with generating templates and structuring mocks, but the logic of interacting between multiple modules and handling errors requires an engineering approach.&lt;br&gt;
&lt;em&gt;Unit Tests:&lt;/em&gt; Unit tests remain the responsibility of developers, but for automation engineers, Cursor can be a useful tool for speeding up test creation, especially for typical cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example of using MCP Playwright in e2e&amp;nbsp;tests&lt;/strong&gt;&lt;br&gt;
Open Google&lt;br&gt;
Search for "Playwright MCP"&lt;br&gt;
Verify that the search results contain the text "Model Context Protocol"&lt;br&gt;
Open Google again&lt;br&gt;
Search for "Playwright automation"&lt;br&gt;
Verify that the search results contain the word "testing"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fic08fffk9ceddqnx44xy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fic08fffk9ceddqnx44xy.png" alt="Image description" width="800" height="346"&gt;&lt;/a&gt;&lt;br&gt;
This test interacts solely with the UI. For such simple scenarios, MCP Playwright will generate the necessary code, ensuring stable and fast test execution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5qdcnb6gnvxny13uapy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5qdcnb6gnvxny13uapy.png" alt="Image description" width="800" height="1064"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration Tests: An AQA Engineer's perspective&lt;/strong&gt;&lt;br&gt;
When it comes to more complex integration tests, Cursor can provide a test template and a structure for mocks, but for complex logic, such as error handling or interaction between multiple modules, you need to apply engineering thinking.&lt;br&gt;
&lt;strong&gt;Example&lt;/strong&gt;&lt;br&gt;
Cursor generated a test for a successful scenario, but it didn't account for all the negative cases related to errors. I had to refine it manually, adding error handling:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rqmn9bdra68b16ck8an.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rqmn9bdra68b16ck8an.png" alt="Image description" width="800" height="100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is an example of how Cursor helps with templates, but you still need to tweak it for more complex scenarios. Also, Cursor tends to overcomplicate solutions, so it's important to monitor what it generates and double-check its work.&lt;br&gt;
Cursor works well in monorepositories and can reuse existing UI and API methods. To limit the complexity of the generated code, you can use Cursor rules. &lt;a href="https://docs.cursor.com/context/rules" rel="noopener noreferrer"&gt;Cursor Rules Documentation&lt;/a&gt;.&lt;br&gt;
Of course, this is not a solution to all problems, but it can significantly simplify your work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests: How Cursor helps speed up test&amp;nbsp;creation&lt;/strong&gt;&lt;br&gt;
Unit tests are primarily the responsibility of developers, but as an automation engineer, you can also get involved, especially if you actively participate in writing code and understand its logic. Cursor helps generate tests quickly, saving time, especially for common cases.&lt;br&gt;
&lt;strong&gt;Example from&amp;nbsp;Practice&lt;/strong&gt;&lt;br&gt;
With Cursor, I generated several tests for functions like formatCurrency and parseQuery. Here's an example of how it might look:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6o90occrh7rfnrxcsnc9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6o90occrh7rfnrxcsnc9.png" alt="Image description" width="800" height="146"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cursor suggested edge cases like null, NaN, and empty strings, which are often forgotten in tests. This saves time, eliminates human errors, and helps make tests more comprehensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to use Cursor to add data-testid or modify buttons on the&amp;nbsp;frontend&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cursor has a great feature for adding a screenshot of the page to a request, which is especially useful if you have a monorepository for both frontend and e2e tests. This is a great opportunity to immediately fix a component and label it in an accessible way while writing a script. Of course after making changes, you should verify the display, but this significantly simplifies the process for beginner automation engineers or those just getting familiar with the frontend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn4spneuc2b6jgqseif6n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn4spneuc2b6jgqseif6n.png" alt="Image description" width="742" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monorepositories - A great solution for these&amp;nbsp;tools&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Although I haven't had the opportunity to work with these tools in a single monorepository where both frontend and backend are located, in practice, when all the automated tests are in the same repository as the frontend, this solution has proven to be excellent. Firstly, you can immediately label the necessary elements on the frontend, and with Cursor, you can quickly pull the required API request or examine how the frontend is structured overall.&lt;br&gt;
Using a monorepository in combination with these tools gives you flexibility and improves collaboration between teams, especially in terms of writing and maintaining tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use MCP Playwright sensibly&lt;/strong&gt;&lt;br&gt;
MCP Playwright is really good for testing interfaces when the page is properly labeled, and the scenarios are simple. It speeds up testing and eliminates the need to manually write locators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use MCP Playwright if:&lt;/strong&gt;&lt;br&gt;
The UI is properly labeled with accessibility attributes.&lt;br&gt;
The page being tested is simple: a form, a button, or a basic navigation.&lt;br&gt;
The test logic is as simple as possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't Use MCP Playwright if:&lt;/strong&gt;&lt;br&gt;
The interface is custom, and the elements do not have accessible attributes.&lt;br&gt;
The test logic is complex, involving multiple interactions or APIs.&lt;br&gt;
You need to check internal values, not just the visual state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
MCP Playwright and Cursor are powerful tools that can significantly speed up test creation and help with routine tasks. However, they cannot replace the work of an engineer, especially in more complex scenarios.&lt;br&gt;
Cursor is excellent for generating test templates and working with common cases. It helps quickly set up the structure of tests but requires refinement for more complex scenarios, such as error handling or complex logic.&lt;br&gt;
MCP works perfectly when the page is properly labeled with accessibility attributes. It eliminates the need to manually write locators, speeding up the testing process. But if the elements on the page are dynamic or not properly labeled, MCP may not be as reliable, and you should consider using other methods for element search.&lt;/p&gt;

&lt;p&gt;In any case, while these tools significantly speed up testing, they do not replace full engineering work. It's important to remember that for complex scenarios or integration tests, despite the convenience of these tools, you will still need manual intervention for correct logic setup.&lt;br&gt;
If you have already used MCP and Cursor in your projects, I'd be interested to hear how these tools helped you in real-world cases.&lt;/p&gt;

</description>
      <category>cursor</category>
      <category>mcp</category>
      <category>playwright</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
