<?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: SAMUELFMEDEIROS</title>
    <description>The latest articles on DEV Community by SAMUELFMEDEIROS (@samuelfmedeiros).</description>
    <link>https://dev.to/samuelfmedeiros</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%2F4190632%2F6de63741-982c-45f2-b373-dd232e8afffd.png</url>
      <title>DEV Community: SAMUELFMEDEIROS</title>
      <link>https://dev.to/samuelfmedeiros</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samuelfmedeiros"/>
    <language>en</language>
    <item>
      <title>Your MCP server works in the terminal and fails in the client — the four causes</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:23:43 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/your-mcp-server-works-in-the-terminal-and-fails-in-the-client-the-four-causes-3ie6</link>
      <guid>https://dev.to/samuelfmedeiros/your-mcp-server-works-in-the-terminal-and-fails-in-the-client-the-four-causes-3ie6</guid>
      <description>&lt;p&gt;“Works here” and “works in the client” are two different programs. These four&lt;br&gt;
failures account for nearly every MCP server that passes the local test and dies on the first&lt;br&gt;
real call — and none of them is the server's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A short connect timeout kills a long call
&lt;/h2&gt;

&lt;p&gt;If the server boots a heavy backend on first use (instrumented browser, container, model), the&lt;br&gt;
connect phase can take a minute. A 5-second &lt;code&gt;connect_timeout&lt;/code&gt; drops the session&lt;br&gt;
&lt;strong&gt;mid-call&lt;/strong&gt;, and the message you get does not say that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connect_timeout: 60   # not 5 — the backend boots lazily&lt;/code&gt;&lt;br&gt;
Size the timeout by the &lt;strong&gt;minimum observed&lt;/strong&gt; boot time, not by a warm call's latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A 401 that is not a dead server
&lt;/h2&gt;

&lt;p&gt;The app answers its health check, the MCP returns 401. That is not downtime: it is the &lt;strong&gt;API&lt;br&gt;
key header missing&lt;/strong&gt; from the MCP entry. The error shows up in the tool, not in the config — so&lt;br&gt;
you debug the wrong layer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;headers:&lt;br&gt;
  X-API-Key: ${WEB_RESEARCH_API_KEY}   # resolved from env, never inline&lt;/code&gt;&lt;br&gt;
Test in two layers: (a) does the app answer? (b) is the header in the MCP entry? Different&lt;br&gt;
failures, same face.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A config read at import is inert
&lt;/h2&gt;

&lt;p&gt;You fix the config file, restart only the server, and it still fails. If the config was read at&lt;br&gt;
&lt;strong&gt;import&lt;/strong&gt; time, it is frozen in memory — the change only applies on the next boot of whoever&lt;br&gt;
imported it. Read it at the point of use, or restart the importer.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Dead session mid-call: &lt;code&gt;outcome UNKNOWN&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;transport session expired … outcome UNKNOWN&lt;/code&gt; means the server restarted under you.&lt;br&gt;
The call did &lt;strong&gt;not&lt;/strong&gt; complete — and “UNKNOWN” is not success. Confirm with the health check's&lt;br&gt;
uptime (a few seconds = it restarted), retry &lt;strong&gt;once&lt;/strong&gt;, and if it repeats, report it.&lt;/p&gt;

&lt;p&gt;Retrying a call that already returned a definitive error burns the budget and hides the real&lt;br&gt;
blocker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you call it done
&lt;/h2&gt;

&lt;p&gt;Verify a fresh config with the cheapest call available (a ping) before real work, and re-verify&lt;br&gt;
after any restart. An MCP server that answers a ping and fails its first tool is a server you&lt;br&gt;
never tested end to end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en/p/ship-your-mcp-server"&gt;See the Ship Your MCP Server skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://dev.to/en"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want this wired into your project? &lt;a href="https://dev.to/en/service"&gt;Guided setup&lt;/a&gt; from&lt;br&gt;
R$ 1.500, with the first cycle run together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en"&gt;← all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>debugging</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The 200 that is not a result: how to verify without fooling yourself</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:22:31 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-200-that-is-not-a-result-how-to-verify-without-fooling-yourself-a78</link>
      <guid>https://dev.to/samuelfmedeiros/the-200-that-is-not-a-result-how-to-verify-without-fooling-yourself-a78</guid>
      <description>&lt;p&gt;The most common way a verification lies is not by erroring: it is by going green.&lt;br&gt;
These four checks cost seconds and prevent the “tested, all good” that tested nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A 200 is not a result
&lt;/h2&gt;

&lt;p&gt;An anti-bot page, a consent wall and a login redirect all return &lt;strong&gt;200&lt;/strong&gt;. Before parsing,&lt;br&gt;
assert a marker you expect to see (the product name, a word from the title). If the marker is not&lt;br&gt;
there, the body belongs to another page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The count has to add up
&lt;/h2&gt;

&lt;p&gt;The tool says &lt;code&gt;total: 5&lt;/code&gt; and you parsed 3. Do not report the 3 you have: re-fetch and&lt;br&gt;
parse in code. A report with fewer items than reality is worse than no report, because it looks&lt;br&gt;
complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Same host, another app
&lt;/h2&gt;

&lt;p&gt;A hostname can point at two different applications (the last rule wins, with no error). Validate&lt;br&gt;
by comparing the response &lt;strong&gt;body&lt;/strong&gt;, not just the status: another app's endpoint can answer 200&lt;br&gt;
in place of the one you expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Unverifiable is not verified
&lt;/h2&gt;

&lt;p&gt;A command that does not exist, a service that is down, a full disk, a stuck machine: the absence&lt;br&gt;
of proof is &lt;strong&gt;never&lt;/strong&gt; proof of success. Write “unverifiable” and the reason, in one line —&lt;br&gt;
instead of letting it look like a pass.&lt;/p&gt;

&lt;p&gt;In a gate that becomes three states, not two: &lt;strong&gt;passed&lt;/strong&gt;, &lt;strong&gt;failed&lt;/strong&gt; (the only one that is&lt;br&gt;
yours) and &lt;strong&gt;unverifiable&lt;/strong&gt; (the machine's). Collapsing the third into “passed” makes the gate&lt;br&gt;
approve without proving; collapsing it into “failed” makes the gate stop enforcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that catches this
&lt;/h2&gt;

&lt;p&gt;Write the check so it &lt;strong&gt;fails when the verification does not happen&lt;/strong&gt; — not one that passes by&lt;br&gt;
omission. A check that only fails when the command runs and errors is measuring the command, not&lt;br&gt;
the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en/p/ai-test-loop"&gt;See the AI Test Loop skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://dev.to/en"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want this wired into your project? &lt;a href="https://dev.to/en/service"&gt;Guided setup&lt;/a&gt; from&lt;br&gt;
R$ 1.500, with the first cycle run together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en"&gt;← all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>development</category>
      <category>testing</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>It passes on your machine and breaks in CI — the causes that are not your code</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 18 Oct 2026 05:53:00 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/it-passes-on-your-machine-and-breaks-in-ci-the-causes-that-are-not-your-code-4on7</link>
      <guid>https://dev.to/samuelfmedeiros/it-passes-on-your-machine-and-breaks-in-ci-the-causes-that-are-not-your-code-4on7</guid>
      <description>&lt;p&gt;“Works on my machine” stopped being a joke when CI became the only place where your&lt;br&gt;
environment is clean. Four causes account for nearly every red CI with identical code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. An unpinned dependency
&lt;/h2&gt;

&lt;p&gt;You changed nothing and CI broke. A dependency published a new version and your&lt;br&gt;
&lt;code&gt;requirements&lt;/code&gt; with no upper bound accepted it. The classic: a library that starts&lt;br&gt;
requiring a version of another that dropped a transitive dependency.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sqlmodel&amp;gt;=0.0.45,&amp;lt;0.0.48   # the ceiling is what prevents the silent break&lt;/code&gt;&lt;br&gt;
Without a ceiling, the next release is a scheduled date for your CI to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The environment is not the same
&lt;/h2&gt;

&lt;p&gt;Different lint version, different Python, or a package installed only on your machine. CI&lt;br&gt;
installs from scratch — which is exactly where it shows you what your environment was hiding.&lt;/p&gt;

&lt;p&gt;Reproduce before blaming CI: run the same command, in the same interpreter, in a clean&lt;br&gt;
environment. If it passes clean, the difference is your environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A file that is not versioned
&lt;/h2&gt;

&lt;p&gt;The test passes because the file exists on your disk — and the file is in &lt;code&gt;.gitignore&lt;/code&gt;&lt;br&gt;
(or was never added). In CI it does not exist and the test fails for a reason that looks&lt;br&gt;
invented.&lt;/p&gt;

&lt;p&gt;One-command check: list the files the test reads and confirm every one is under version&lt;br&gt;
control.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A cache that lies
&lt;/h2&gt;

&lt;p&gt;A dependency or build cache can mask the failure: the job passes using an old artifact. If the&lt;br&gt;
same commit fails without the cache and passes with it, the problem is the cache — and it will come&lt;br&gt;
back.&lt;/p&gt;

&lt;h2&gt;
  
  
  A diagnosis order that works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the exact annotation&lt;/strong&gt;CI tells you file and line. Go there before&lt;br&gt;
theorising.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compare versions&lt;/strong&gt;Interpreter and dependencies in CI vs yours. The difference is&lt;br&gt;
usually there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run clean locally&lt;/strong&gt;New environment, no cache, same version. Reproduced? It is the&lt;br&gt;
code. Not reproduced? It is the environment.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://dev.to/en/p/ship-your-mcp-server"&gt;See the Ship Your MCP Server skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://dev.to/en"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want this wired into your project? &lt;a href="https://dev.to/en/service"&gt;Guided setup&lt;/a&gt; from&lt;br&gt;
R$ 1.500, with the first cycle run together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en"&gt;← all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your agent said “it works”. That is an opinion, not a result</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 18 Oct 2026 05:52:22 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/your-agent-said-it-works-that-is-an-opinion-not-a-result-2hp5</link>
      <guid>https://dev.to/samuelfmedeiros/your-agent-said-it-works-that-is-an-opinion-not-a-result-2hp5</guid>
      <description>&lt;p&gt;When the same context writes the work and approves it, “looks done” becomes the bar.&lt;br&gt;
A separate critic that runs real commands and returns a 0-100 score turns that into evidence — or&lt;br&gt;
names exactly what is missing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: the builder grades its own homework
&lt;/h2&gt;

&lt;p&gt;An agent reviewing its own output reviews nothing: it confirms the intent it already had. The&lt;br&gt;
symptoms are familiar — review rounds that never converge, “it's done” repeated, and the defect&lt;br&gt;
showing up later in the hands of whoever trusted it.&lt;/p&gt;

&lt;p&gt;The fix is not asking the model to “be rigorous”. It is moving the score out of the agent: a&lt;br&gt;
script that measures with real commands, and a separate context that audits the score.&lt;/p&gt;
&lt;h2&gt;
  
  
  Four roles, never merged into one context
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Orchestrator&lt;/strong&gt;the agent running the task: picks the checks, drives the rounds, fixes&lt;br&gt;
infrastructure and owns the report.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Builder&lt;/strong&gt;whoever wrote the artifact: fixes one class of gap per pass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Critic&lt;/strong&gt;a script you write (&lt;code&gt;loop_runner.py&lt;/code&gt; + your checks): executes&lt;br&gt;
every command, keeps the raw output and prints &lt;code&gt;SCORE=&lt;/code&gt; and &lt;code&gt;GAPS=&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reviewer&lt;/strong&gt;a different agent/model in a fresh context, with no justification from the&lt;br&gt;
builder: it can reopen the loop.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule zero: builder ≠ critic ≠ reviewer. If one context does all three, the loop is theatre.&lt;/p&gt;
&lt;h2&gt;
  
  
  The loop, step by step
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Declare the checks before writing code&lt;/strong&gt;For each check: name, command, weight and what&lt;br&gt;
“pass” means. Weights sum to 100.&lt;br&gt;
&lt;code&gt;{"checks":[{"name":"pytest","cmd":"set -o pipefail; pytest -q","weight":40},&lt;/code&gt;&lt;br&gt;
&lt;code&gt;{"name":"no_secret_scan","cmd":"gitleaks detect --no-banner","weight":30}]}&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run the critic&lt;/strong&gt;It executes every command for real, captures the evidence and never&lt;br&gt;
asks a model whether the work is good — it measures.&lt;br&gt;
&lt;code&gt;python scripts/loop_runner.py --config loop.json --round 1&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Score, compare, decide&lt;/strong&gt;Above the threshold goes to the reviewer; below, take the&lt;br&gt;
&lt;strong&gt;largest&lt;/strong&gt; gap, fix it and run again. Cap the rounds (8 is plenty): if it never converges, the&lt;br&gt;
check set is wrong, not the code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Independent review before delivery&lt;/strong&gt;Same artifact, fresh context, no justification&lt;br&gt;
from the builder. A panel of 2-3 specialists (quality and security always) returning strict&lt;br&gt;
JSON.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security gate, fail-closed&lt;/strong&gt;A secret in the diff or history, a high-severity finding,&lt;br&gt;
an auth/path-traversal/injection issue block delivery &lt;strong&gt;even&lt;/strong&gt; with perfect tests. An&lt;br&gt;
inconclusive answer counts as blocked, never as pass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Report with score, evidence and what was not verifiable&lt;/strong&gt;The report states what was&lt;br&gt;
measured, with raw output, and what could not be checked — no “probably fine”.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What the output actually looks like
&lt;/h2&gt;

&lt;p&gt;Red is the broken case (the gate blocks); green is the one that ships. Both are real runs, not an&lt;br&gt;
illustration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;`$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python scripts/loop_runner.py &lt;span class="nt"&gt;--config&lt;/span&gt; loop.json &lt;span class="nt"&gt;--round&lt;/span&gt; 3
&lt;span class="go"&gt;SCORE=55
GAPS=[pytest::exit_1, no_secret_scan::missing]
GATE=failed
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;1

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python scripts/loop_runner.py &lt;span class="nt"&gt;--config&lt;/span&gt; loop.json &lt;span class="nt"&gt;--round&lt;/span&gt; 7
&lt;span class="go"&gt;SCORE=100
GATE=passed
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0`
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four ways a green loop lies
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Piping hides the exit code&lt;/strong&gt;&lt;code&gt;pytest | tail&lt;/code&gt; returns the exit code of&lt;br&gt;
&lt;code&gt;tail&lt;/code&gt; (0), so the critic scores 100 with a failing test hidden in the output. Use&lt;br&gt;
&lt;code&gt;set -o pipefail&lt;/code&gt; inside every check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A timeout shorter than the check is a silent zero&lt;/strong&gt;The critic is killed by its own&lt;br&gt;
timeout, the orchestrator reads score 0 with an empty gap list, and loops with nothing to fix. Set&lt;br&gt;
the timeout above the real duration and always keep the critic's raw log.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A short-circuiting “pass”&lt;/strong&gt;A server answering 200 may be the wrong server: on a port&lt;br&gt;
collision the check tests a different app and fails for the wrong reason. Assert&lt;br&gt;
&lt;strong&gt;identity&lt;/strong&gt; (title, version, a known payload), not just reachability.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A stale baseline regenerated to go green&lt;/strong&gt;Regenerating a visual baseline “to pass”&lt;br&gt;
erases the regression the test exists to catch. Baselines change by explicit decision, justified in&lt;br&gt;
the history — never to silence a check.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changes day to day
&lt;/h2&gt;

&lt;p&gt;The point is not the score: it is the conversation. “Is it done?” stops being the builder's&lt;br&gt;
opinion and becomes &lt;code&gt;SCORE=100&lt;/code&gt; with the command output attached — or a gap list with&lt;br&gt;
names and weights. When something could not be verified, it is written down instead of turning into&lt;br&gt;
a false green.&lt;/p&gt;

&lt;p&gt;The same pattern holds for any deliverable: code, a pipeline, a data migration, a document&lt;br&gt;
someone will use to decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start now
&lt;/h2&gt;

&lt;p&gt;The skill that does this ships as &lt;strong&gt;AI Test Loop&lt;/strong&gt; — the package includes&lt;br&gt;
&lt;code&gt;loop_runner.py&lt;/code&gt;, the scoring rubric, the reviewer prompt and the security checks. The&lt;br&gt;
repository is open (MIT) and the store version adds the ready-made guards.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en/p/ai-test-loop"&gt;See the AI Test Loop skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://dev.to/en/gratis/ai-test-loop"&gt;Get the free version&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rather have it installed in your project? &lt;a href="https://dev.to/en/service"&gt;Guided setup&lt;/a&gt; from&lt;br&gt;
R$ 1.500, with the first cycle run together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/en"&gt;← all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your visual test failed. Is it the page, or the capture?</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sat, 17 Oct 2026 18:06:12 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/your-visual-test-failed-is-it-the-page-or-the-capture-3ccn</link>
      <guid>https://dev.to/samuelfmedeiros/your-visual-test-failed-is-it-the-page-or-the-capture-3ccn</guid>
      <description>&lt;p&gt;Your visual test failed. Is it the page, or the capture?&lt;/p&gt;

&lt;p&gt;A screenshot test fails for two opposite reasons, and the response is opposite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The page changed&lt;/strong&gt; → block the release. That is what the test is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page did not change, the capture is not reproducible&lt;/strong&gt; → fix the capture, not the UI. Animation mid-frame, blinking caret, timestamp, ad slot, font fallback, scrollbar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A gate that cannot separate the two becomes noise, and someone turns it off within a week. Keeping them apart is deterministic by construction: same inputs, same verdict. No AI in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four verdicts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Within the pixel budget and the region limit&lt;/td&gt;
&lt;td&gt;Ship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Budget exceeded, regions listed as x/y/w/h&lt;/td&gt;
&lt;td&gt;Look at &lt;code&gt;diff.png&lt;/code&gt; and the region crops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;STALE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The baseline does not belong to this build (&lt;code&gt;git HEAD&lt;/code&gt;, mask config or the PNG changed)&lt;/td&gt;
&lt;td&gt;Re-baseline deliberately, or unstage the change — never "just re-run"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ERROR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unreadable PNG or size mismatch&lt;/td&gt;
&lt;td&gt;That is a capture bug (viewport changed?), not a regression&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;STALE&lt;/code&gt; exists because the classic failure of visual testing is not flakiness — it is a baseline quietly re-generated until the test went green. Tying the baseline to &lt;code&gt;git HEAD&lt;/code&gt; plus a hash of the mask config turns re-baselining into a deliberate, reviewable act.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes are the contract
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 PASS   1 FAIL   2 STALE   3 ERROR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a &lt;code&gt;--json&lt;/code&gt; output you get the verdict, the diff percentage, the region count and every region's bounding box — enough for CI to publish &lt;strong&gt;what&lt;/strong&gt; moved instead of "the test failed".&lt;/p&gt;

&lt;h2&gt;
  
  
  Budgets that survive contact with reality
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--max-diff&lt;/code&gt; is a safety net, not the primary signal.&lt;/strong&gt; Antialiasing and font hinting shift a few thousand pixels with zero real change; a real component move shifts tens of thousands. Start at &lt;code&gt;0.001&lt;/code&gt; (0.1%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--max-regions&lt;/code&gt; is the signal that catches small, meaningful changes.&lt;/strong&gt; A wrong colour on a 60×40 button is ~0.26% of a 1280×720 page, but exactly &lt;strong&gt;one&lt;/strong&gt; region. Budget: &lt;code&gt;1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masks&lt;/strong&gt; remove whole rectangles and are &lt;strong&gt;reported&lt;/strong&gt; (&lt;code&gt;masked_pixels&lt;/code&gt;) — nobody hides a regression inside one without it showing in the report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Determinism checklist (before blaming the gate)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Fixed viewport and DPR 1 — DPR scales the layout and invalidates every baseline&lt;/li&gt;
&lt;li&gt;Disable animation (&lt;code&gt;prefers-reduced-motion&lt;/code&gt; + &lt;code&gt;animation:none!important&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Wait for &lt;code&gt;document.fonts.ready&lt;/code&gt; and network idle — a fallback font diffs the whole page&lt;/li&gt;
&lt;li&gt;Hide the caret, the scrollbar and the dev overlay&lt;/li&gt;
&lt;li&gt;Freeze time and randomness&lt;/li&gt;
&lt;li&gt;Mask only what is genuinely dynamic (ad slot, live counter, A/B slot)&lt;/li&gt;
&lt;li&gt;Capture in the same container as CI — a different font stack means different pixels everywhere&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The gate has to be tested in both directions
&lt;/h2&gt;

&lt;p&gt;A gate tested only against "nothing changed" is decoration. The one I run has 15 tests, and both sides are asserted — including the case that bites in production: an optimised baseline (&lt;code&gt;optipng&lt;/code&gt;, &lt;code&gt;pngquant&lt;/code&gt;, ImageMagick) that a naive decoder refuses. The PNG decoder is stdlib (zlib + struct): a CI gate that needs a C library on every machine breaks on the machine that matters.&lt;/p&gt;




&lt;p&gt;Full write-up with the exact commands: &lt;a href="https://skills.seu.pet/blog/visual-tests-that-lie" rel="noopener noreferrer"&gt;Your visual test failed: is it the page or the capture?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>ci</category>
    </item>
  </channel>
</rss>
