<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Better world by better software</title>
  
  <subtitle>Gleb Bahmutov PhD</subtitle>
  <link href="/blog/atom.xml" rel="self"/>
  
  <link href="https://glebbahmutov.com/blog/"/>
  <updated>2026-04-08T13:52:59.391Z</updated>
  <id>https://glebbahmutov.com/blog/</id>
  
  <author>
    <name>Gleb Bahmutov</name>
    
  </author>
  
  <generator uri="http://hexo.io/">Hexo</generator>
  
  <entry>
    <title>Testing A11y Using Cypress And wick-a11y Plugin</title>
    <link href="https://glebbahmutov.com/blog/testing-a11y/"/>
    <id>https://glebbahmutov.com/blog/testing-a11y/</id>
    <published>2026-04-07T04:00:00.000Z</published>
    <updated>2026-04-08T13:52:59.391Z</updated>
    
    <content type="html"><![CDATA[<p>I have a little web app called <a href="https://glebbahmutov.com/gametime/">Gametime</a> I use to keep track of players during youth soccer matches. Recently I decided to add a11y testing to ensure the text is readable, inputs have labels, etc. Since I already had Cypress end-to-end tests covering all features of the app, I just needed 60 seconds to ensure the web app is accessible to everyone.</p><h2><span id="install-the-wick-a11y-plugin">Install the wick-a11y plugin</span></h2><p>First, let&#39;s install <a href="https://github.com/sclavijosuero/wick-a11y">wick-a11y</a> plugin.</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">npm i -D wick-a11y</span></span><br><span class="line">+wick-a11y@3.0.1</span><br></pre></td></tr></table></figure><p>Register the plugin&#39;s tasks in the Cypress config file</p><figure class="highlight js"><figcaption><span>cypress.config.mjs</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;cypress&#x27;</span></span><br><span class="line"><span class="comment">// https://github.com/sclavijosuero/wick-a11y</span></span><br><span class="line"><span class="keyword">import</span> addAccessibilityTasks <span class="keyword">from</span> <span class="string">&#x27;wick-a11y/accessibility-tasks&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  ...</span><br><span class="line">  <span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">    <span class="title function_">addAccessibilityTasks</span>(on, config)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Include the plugin&#39;s custom commands by importing it from the support file loaded by the browser</p><figure class="highlight ts"><figcaption><span>cypress/support/e2e.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// https://github.com/sclavijosuero/wick-a11y</span></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;wick-a11y&#x27;</span></span><br></pre></td></tr></table></figure><p>That&#39;s it. Now I can confirm the page is accessible by calling <code>cy.checkAccessibility()</code> command.</p><h2><span id="check-a11y">Check a11y</span></h2><p>Let&#39;s confirm the Teams page works for everyone. I simply add the accessibility command at the end of the test</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;enables Add button when typing the name&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/team/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;teams-container&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;Add team&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=teamName]&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Name&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;Add team&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">checkAccessibility</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Right away it shows 1 critical problem: the text &quot;Zero players&quot; has a color that is hard to read on white background.</p><p><img src="../images/testing-a11y/err1.png" alt="Color contrast problem"></p><p>You can get the error details by clicking on the &quot;Fixme&quot; log line in the Command Log</p><p><img src="../images/testing-a11y/log1.png" alt="A11y error details"></p><p>The fix is simple: use a darker gray color class</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">  &lt;div</span><br><span class="line">    data-cy=&quot;zero-teams&quot;</span><br><span class="line"><span class="deletion">-   class=&quot;flex h-24 items-center justify-center text-gray-400&quot;</span></span><br><span class="line"><span class="addition">+   class=&quot;flex h-24 items-center justify-center text-gray-500&quot;</span></span><br><span class="line">  &gt;</span><br><span class="line">    No teams yet</span><br><span class="line">  &lt;/div&gt;</span><br></pre></td></tr></table></figure><p><strong>Note:</strong> this plugin by default checks. If you want to see lower severity errors, enable them when running the command; could be verbose at first.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">checkAccessibility</span>(<span class="literal">undefined</span>, &#123;</span><br><span class="line">  <span class="comment">// fail on critical errors</span></span><br><span class="line">  <span class="attr">includedImpacts</span>: [<span class="string">&#x27;critical&#x27;</span>, <span class="string">&#x27;serious&#x27;</span>],</span><br><span class="line">  <span class="comment">// only warn for non-critical errors</span></span><br><span class="line">  <span class="attr">onlyWarnImpacts</span>: [<span class="string">&#x27;moderate&#x27;</span>, <span class="string">&#x27;minor&#x27;</span>],</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Our page is missing <code>&lt;main&gt;</code> landmark (and other landmarks, like <code>&lt;footer&gt;</code>), so we get several warnings, highlighted in yellow</p><p><img src="../images/testing-a11y/warnings.png" alt="Low level A11y errors"></p><h2><span id="detailed-reports">Detailed reports</span></h2><p>This plugin generates detailed static HTML reports (which you can control). Open the report file from <code>cypress/accessibility</code> folder. Each error has a separate section, showing the HTML snapshot with offending element.</p><p><img src="../images/testing-a11y/report.png" alt="Text contrast violation"></p><p>I suggest saving <code>cypress/accessibility</code> folder as a test artifact on your CI. If the <code>cy.checkAccessibility</code> command fails, these reports will make fixing it a breeze.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I have a little web app called &lt;a href=&quot;https://glebbahmutov.com/gametime/&quot;&gt;Gametime&lt;/a&gt; I use to keep track of players during youth socc
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="a11y" scheme="https://glebbahmutov.com/blog/tags/a11y/"/>
    
  </entry>
  
  <entry>
    <title>String Types For E2E Tests</title>
    <link href="https://glebbahmutov.com/blog/string-types-for-e2e-tests/"/>
    <id>https://glebbahmutov.com/blog/string-types-for-e2e-tests/</id>
    <published>2026-03-21T04:00:00.000Z</published>
    <updated>2026-03-22T16:41:24.332Z</updated>
    
    <content type="html"><![CDATA[<p>Every individual item sold on <a href="https://www.mercari.com/">Mercari.com</a> has an id that looks like <code>m&lt;number&gt;</code>. The item&#39;s id is visible in the URL, for example <a href="https://www.mercari.com/us/item/m73702188949/">www.mercari.com/us/item/m73702188949/</a>. If you buy several items from the same seller, you get a discount because you buy it as a bundle. Every bundle has its own unique id that looks like <code>b&lt;number&gt;</code>. Both ids are strings, yet they have a certain format that differs. In our tests we don&#39;t want to be confused which type we are passing: is it an item id; a bundle id; a random string we pass as id by mistake?</p><p>Here is where TypeScript string template literal types come in very handy. Let&#39;s define an <code>ItemId</code> type.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">ItemId</span> = <span class="string">`m<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id1</span>: <span class="title class_">ItemId</span> = <span class="string">&#x27;m123&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id2</span>: <span class="title class_">ItemId</span> = <span class="string">&#x27;x123&#x27;</span> <span class="comment">// invalid, does not start with &#x27;m&#x27;</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id3</span>: <span class="title class_">ItemId</span> = <span class="string">&#x27;mabc&#x27;</span> <span class="comment">// invalid, does not end with a number</span></span><br></pre></td></tr></table></figure><p>TypeScript immediately complains about &quot;x123&quot; and &quot;mabc&quot; strings - these are NOT item ids. My VSCode editor highlights the errors</p><p><img src="../images/string-types-for-e2e-tests/errors.png" alt="Item ID type checks"></p><p>We don&#39;t have to run a test to know it does not work; static types check tells us about our mistake.</p><p>If we have a runtime ID value (which we could load from a network call for example), we can confirm and typecast it using <code>is a &lt;type&gt;</code> syntax. For example, here is utility function to check if a string is an item id:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">ItemId</span> = <span class="string">`m<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id1</span>: <span class="title class_">ItemId</span> = <span class="string">&#x27;m123&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">isItemId</span>(<span class="params">value: <span class="built_in">string</span></span>): value is <span class="title class_">ItemId</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="regexp">/^m\d+$/</span>.<span class="title function_">test</span>(value)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">isItemId</span>(id1)) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">`<span class="subst">$&#123;id1&#125;</span> is a valid ItemId`</span>)</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">  <span class="comment">// never happens</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;invalid id1&#x27;</span>, id1)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>If we look at the &quot;else&quot; branch, we can see the type inference; TS can safely say that &quot;id1&quot; in the &quot;else&quot; branch has &quot;never&quot; type, meaning this code should be unreachable.</p><p><img src="../images/string-types-for-e2e-tests/never.png" alt="Static type check using isItemId function"></p><p>We can go beyond a predicate, we can write a utility function that throws an exception if it is given an invalid string value; and this function tells TypeScript compiler that the input has certain type afterwards.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">ItemId</span> = <span class="string">`m<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id1</span>: <span class="title class_">ItemId</span> = <span class="string">&#x27;m123&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">assertItemId</span>(<span class="params">id: <span class="built_in">string</span></span>): asserts id is <span class="title class_">ItemId</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (!<span class="regexp">/^m\d+$/</span>.<span class="title function_">test</span>(id)) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`Invalid ItemId: <span class="subst">$&#123;id&#125;</span>`</span>)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">id2</span>: <span class="built_in">string</span> = <span class="string">&#x27;m123&#x27;</span></span><br><span class="line"><span class="title function_">assertItemId</span>(id2) <span class="comment">// Type assertion</span></span><br><span class="line"><span class="comment">// Now id2 is treated as ItemId</span></span><br></pre></td></tr></table></figure><p>What about other strings, like phone numbers? Let&#39;s say that every US phone number we have to store must be fully formatted. We can define a type:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">PhoneNumber</span> = <span class="string">`+1-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone1</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-123-456-7890&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone2</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;123-456-7890&#x27;</span> <span class="comment">// invalid, missing country code</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone3</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-123-4567-890&#x27;</span> <span class="comment">// invalid, incorrect format, NOT DETECTED</span></span><br></pre></td></tr></table></figure><p>Looks good, but notice my comment on the <code>phone3</code> - TypeScript does <strong>not</strong> flag its incorrect format.</p><p><img src="../images/string-types-for-e2e-tests/phones.png" alt="Trying to type check phone numbers"></p><p>Hmm, we do detect the &quot;abridged&quot; phone number strings, yet TS &quot;missed&quot; the obvious problem. Even worse, TS will miss completely wrong numbers!</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">PhoneNumber</span> = <span class="string">`+1-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>-<span class="subst">$&#123;<span class="built_in">number</span>&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone1</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-123-456-7890&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone2</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-1-2-3&#x27;</span> <span class="comment">// no TS errors!</span></span><br></pre></td></tr></table></figure><p>Look at the <code>PhoneNumber</code> type definition: <code>+1-$&#123;number&#125;-$&#123;number&#125;-$&#123;number&#125;</code> says nothing about how <em>many digits</em> each number part should have. Thus the string &quot;+1-1-2-3&quot; satisfies the type, yet it is a bad phone number string.</p><p>Let&#39;s step back for a second and look at the bank card codes or credit card CVV codes. We can define a type for a string that is just 4 digits in a row:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> digit = <span class="string">&#x27;0&#x27;</span> | <span class="string">&#x27;1&#x27;</span> | <span class="string">&#x27;2&#x27;</span> | <span class="string">&#x27;3&#x27;</span> | <span class="string">&#x27;4&#x27;</span> | <span class="string">&#x27;5&#x27;</span> | <span class="string">&#x27;6&#x27;</span> | <span class="string">&#x27;7&#x27;</span> | <span class="string">&#x27;8&#x27;</span> | <span class="string">&#x27;9&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">PinCode</span> = <span class="string">`<span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">pin1</span>: <span class="title class_">PinCode</span> = <span class="string">&#x27;1234&#x27;</span> <span class="comment">// valid</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">pin2</span>: <span class="title class_">PinCode</span> = <span class="string">&#x27;123&#x27;</span> <span class="comment">// invalid, too short</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">pin3</span>: <span class="title class_">PinCode</span> = <span class="string">&#x27;12345&#x27;</span> <span class="comment">// invalid, too long</span></span><br></pre></td></tr></table></figure><p>Look at the TS error given for <code>pin2</code> and <code>pin3</code></p><p><img src="../images/string-types-for-e2e-tests/pincode.png" alt="TS errors for invalid pin codes"></p><p>TypeScript does NOT tell us &quot;123 is too short&quot; or &quot;123 does not match digit+digit+digit+digit&quot;, instead it shows us how such string literal type works. It simply &quot;expands&quot; every possible combination. String type <code>PinCode</code> is a union of ALL strings like <code>0000 | 0001 | 0002 | ... | 9999</code>. Wow, brute force works!</p><p>Ok, let&#39;s use the <code>digit</code> type to form a better phone number type.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> digit = <span class="string">&#x27;0&#x27;</span> | <span class="string">&#x27;1&#x27;</span> | <span class="string">&#x27;2&#x27;</span> | <span class="string">&#x27;3&#x27;</span> | <span class="string">&#x27;4&#x27;</span> | <span class="string">&#x27;5&#x27;</span> | <span class="string">&#x27;6&#x27;</span> | <span class="string">&#x27;7&#x27;</span> | <span class="string">&#x27;8&#x27;</span> | <span class="string">&#x27;9&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// more accurate phone number type DOES NOT WORK</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">PhoneNumber</span> =</span><br><span class="line">  <span class="string">`+1-<span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span>-<span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span>-<span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span><span class="subst">$&#123;digit&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone1</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-123-456-7890&#x27;</span> <span class="comment">// valid?</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone2</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;123-456-7890&#x27;</span> <span class="comment">// invalid?</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">phone3</span>: <span class="title class_">PhoneNumber</span> = <span class="string">&#x27;+1-123-4567-890&#x27;</span> <span class="comment">// invalid?</span></span><br></pre></td></tr></table></figure><p>Does the above code work? I don&#39;t see any TS errors, hmm. What is the <code>PhoneNumber</code> type, let&#39;s hover over it</p><p><img src="../images/string-types-for-e2e-tests/phone-any.png" alt="TypeScript cannot build a string literal type for PhoneNumber"></p><p>There are too many combinations for the <code>PhoneNumber</code> string literal type, so TS does NOT expand it into <code>+1-000-000-0000 | +1-000-000-0001 | ...</code> - there would be way too many strings in that union to keep track. From my checks, anything longer than 4 digits is not handled, so US 5-digit zip codes are out.</p><p>Ok, so we saw string template literal types, how they work under the hood, and even their limitations. One more note, if you need to check how your web application handles invalid inputs, you do NOT need to special types. You can simply use strings. Let&#39;s confirm that entering an invalid phone number leads to an error:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="attr">invalidNumbers</span>: <span class="built_in">string</span>[] = [</span><br><span class="line">  <span class="string">&#x27;123-456-7890&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;+1-123-4567-890&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;+1-1-2-3&#x27;</span></span><br><span class="line">]</span><br><span class="line"><span class="comment">// confirm that entering invalid phone number</span></span><br><span class="line"><span class="comment">// leads the app to show an error message</span></span><br><span class="line">invalidNumbers.<span class="title function_">forEach</span>(<span class="function"><span class="params">number</span> =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#error&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#phone&#x27;</span>).<span class="title function_">clear</span>().<span class="title function_">type</span>(<span class="built_in">number</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#error&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#phone&#x27;</span>).<span class="title function_">clear</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The above Cypress test simply verifies that entering a bad phone number string is handled by the app. Aside from this test, your testing code probably should <em>only</em> accept <code>PhoneNumber</code>. If you must make an exception (for checking the error handling for example), you can be explicit and use <a href="https://www.totaltypescript.com/concepts/how-to-use-ts-expect-error">@ts-expect-error</a> when calling it:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title class_">ItemPage</span> = &#123;</span><br><span class="line">  <span class="title function_">visit</span>(<span class="params">id: ItemId, errorPage: <span class="built_in">boolean</span> = <span class="literal">false</span></span>) &#123;</span><br><span class="line">    ...</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// @ts-expect-error - confirm the error is shown for non-existent item ids</span></span><br><span class="line"><span class="title class_">ItemPage</span>.<span class="title function_">visit</span>(<span class="string">&#x27;invalid-item-id&#x27;</span>, <span class="literal">true</span>)</span><br></pre></td></tr></table></figure><p>Without <code>@ts-expect-error</code> our TS compiler would not let us pass non-item id string into the page object method <code>ItemPage.visit</code>, but we do want to pass it.</p><h2><span id="learn-more">Learn more</span></h2><p>We can go beyond string literal types into <a href="/blog/branded-types/" title="branded types">branded types</a> to represent things like currencies and time durations. I also suggest reading <a href="https://rahuulmiishra.medium.com/6-typescript-tips-to-write-safer-cleaner-code-24c23c470107">6 TypeScript Tips to Write Safer, Cleaner Code</a> article. Finally, if you are new to TypeScript, check my blog post <a href="/blog/trying-typescript/" title="Trying TypeScript">Trying TypeScript</a>.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Every individual item sold on &lt;a href=&quot;https://www.mercari.com/&quot;&gt;Mercari.com&lt;/a&gt; has an id that looks like &lt;code&gt;m&amp;lt;number&amp;gt;&lt;/code&gt;. 
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="typescript" scheme="https://glebbahmutov.com/blog/tags/typescript/"/>
    
  </entry>
  
  <entry>
    <title>Public Environment Variables For Your Tests Using cypress-expose Plugin</title>
    <link href="https://glebbahmutov.com/blog/cypress-expose-plugin/"/>
    <id>https://glebbahmutov.com/blog/cypress-expose-plugin/</id>
    <published>2026-03-12T04:00:00.000Z</published>
    <updated>2026-03-12T18:20:40.170Z</updated>
    
    <content type="html"><![CDATA[<p>Recently Cypress announced a change in how it will handle environment variables. Variables were always public and accessible to the application under test in the browser (via calling <code>Cypress.env()</code> method), but now you can have truly private values. I published a blog post <a href="/blog/cypress-expose/" title="Migrating From Cypress.env To cy.env and Cypress.expose Methods">Migrating From Cypress.env To cy.env and Cypress.expose Methods</a> describing the change.</p><p>Looking at the changes, I noticed a missing area: it is not easy to pass <em>public</em> variables (non-secrets) to Cypress tests. I used a lot of &quot;configuration&quot; values that were meant to be used inside the spec files, and now these values are private by default. For example, let&#39;s say we want to run a test <em>only</em> on CI. We could do something like this:</p><figure class="highlight js"><figcaption><span>cypress/e2e/ci.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// only run these tests on CI</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;ci&#x27;</span>)) &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;has CI variable&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="title function_">expect</span>(<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;ci&#x27;</span>), <span class="string">&#x27;CI variable&#x27;</span>).<span class="property">to</span>.<span class="title function_">equal</span>(<span class="literal">true</span>)</span><br><span class="line">    <span class="title function_">expect</span>(<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;ciName&#x27;</span>), <span class="string">&#x27;CI name variable&#x27;</span>).<span class="property">to</span>.<span class="title function_">equal</span>(</span><br><span class="line">      <span class="string">&#x27;GitHub Actions&#x27;</span>,</span><br><span class="line">    )</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>In Cypress before v15 we could have set &#x2F; used &quot;ci&quot; and &quot;ciName&quot; via environment variables, for example, if using <a href="https://github.com/cypress-io/github-action">Cypress github action</a>:</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">ci</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">test:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">tests</span> <span class="string">🧪</span></span><br><span class="line">        <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v7</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="comment"># default CI variable</span></span><br><span class="line">          <span class="attr">CYPRESS_ci:</span> <span class="literal">true</span></span><br><span class="line">          <span class="attr">CYPRESS_ciName:</span> <span class="string">&#x27;GitHub Actions&#x27;</span></span><br></pre></td></tr></table></figure><p>The test runs, <code>Cypress.env()</code> returns <code>&#123; ci: true, ciName: &#39;GitHub Actions&#39; &#125;</code>, everyone is happy. But in Cypress v16 the process environment variables that start with <code>CYPRESS_</code> are automatically added to the <em>private</em> <code>cy.env</code> space, and are not accessible outside test blocks &#x2F; hooks. We could still pass them, but now we need to use a complete custom <code>cypress run --expose ...</code> command, instead of letting Cypress GitHub Action do its thing!</p><p>This is why I wrote <a href="https://github.com/bahmutov/cypress-expose">cypress-expose</a> plugin. It grabs every <code>CYPRESS_EXPOSE_...</code> process environment variable and sets it into <code>Cypress.expose</code> object, letting the specs access it. This is meant for public data only! Simply add plugin to your <code>setupNodeEvents</code> callback function and pass the <code>config</code> object.</p><figure class="highlight js"><figcaption><span>cypress.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> &#123; defineConfig &#125; = <span class="built_in">require</span>(<span class="string">&#x27;cypress&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> cypressExpose = <span class="built_in">require</span>(<span class="string">&#x27;cypress-expose&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">allowCypressEnv</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">e2e</span>: &#123;</span><br><span class="line">    <span class="comment">// baseUrl, etc</span></span><br><span class="line">    <span class="attr">supportFile</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">fixturesFolder</span>: <span class="literal">false</span>,</span><br><span class="line"></span><br><span class="line">    <span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">      <span class="title function_">cypressExpose</span>(config)</span><br><span class="line"></span><br><span class="line">      <span class="comment">// IMPORTANT: return the config object</span></span><br><span class="line">      <span class="comment">// to let Cypress know we modified it</span></span><br><span class="line">      <span class="keyword">return</span> config</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Boom, you are all set. Now any <code>CYPRESS_EXPOSE_...</code> variable is available under camel-cased name. Let&#39;s say we run Cypress using the following command:</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">CYPRESS_API_KEY=123secret! \</span></span><br><span class="line"><span class="language-bash">  CYPRESS_EXPOSE_ANSWER=42 \</span></span><br><span class="line"><span class="language-bash">  cypress open --<span class="built_in">env</span> PASS_WORD=my-password --expose USER_NAME=Joe</span></span><br></pre></td></tr></table></figure><p>The api key and the password are going to be reachable via <code>cy.env</code> command, while the answer and the username are publicly accessible using <code>Cypress.expose</code>:</p><p><img src="../images/cypress-expose-plugin/expose.png" alt="cypress-expose &quot;moved&quot; the environment variable &quot;answer&quot; to be publicly accessible in the browser"></p><p>The <code>EXPOSE_ANSWER</code> value in the <code>cy.env</code> is <code>null</code>, the plugin overwrites it, but cannot delete it (since Cypress merged any modifications to the <code>env</code> object with the original values). I feel like having <code>null</code> there is enough.</p><p>Take the plugin for a spin, let me know if it works for you.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Recently Cypress announced a change in how it will handle environment variables. Variables were always public and accessible to the appli
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
  </entry>
  
  <entry>
    <title>Migrating From Cypress.env To cy.env and Cypress.expose Methods</title>
    <link href="https://glebbahmutov.com/blog/cypress-expose/"/>
    <id>https://glebbahmutov.com/blog/cypress-expose/</id>
    <published>2026-03-07T05:00:00.000Z</published>
    <updated>2026-03-07T04:58:27.758Z</updated>
    
    <content type="html"><![CDATA[<p>Cypress <a href="https://on.cypress.io/changelog#15-10-0">v15.10.0</a> has announced a big switch coming in v16 - the new way of dealing with environment values and secrets. Let&#39;s see why this change is necessary, what is means for your testing code, and what the best practices for handling secrets in your tests should be.</p><p>First, let me say: Cypress end-to-end browser tests run in the browser. This is <a href="/blog/cy-vs-pw-browser/" title="different from Playwright">different from Playwright</a> and different from how other test <a href="/blog/cypress-vs-other-test-runners/" title="runners execute tests">runners execute tests</a>. When you run <code>cypress open</code> or <code>cypress run</code>, the Cypress Electron-based binary starts the Node process, launches the browser, loads the e2e spec into the browser, and lets it run. So let&#39;s imagine we have a few environment variables that we are going to use during the test:</p><figure class="highlight js"><figcaption><span>cypress.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;cypress&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">e2e</span>: &#123;</span><br><span class="line">    <span class="attr">env</span>: &#123;</span><br><span class="line">      <span class="attr">username</span>: <span class="string">&#x27;joe@acme.co&#x27;</span>,</span><br><span class="line">      <span class="attr">password</span>: <span class="string">&#x27;secret!&#x27;</span>,</span><br><span class="line">      <span class="attr">apiToken</span>: <span class="string">&#x27;abc123$v101&#x27;</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">      <span class="title function_">on</span>(<span class="string">&#x27;task&#x27;</span>, &#123;</span><br><span class="line">        <span class="title function_">checkUserSession</span>(<span class="params">&#123; username, token &#125;</span>) &#123;</span><br><span class="line">          <span class="comment">// check DB using &#123; username, token &#125;</span></span><br><span class="line">        &#125;</span><br><span class="line">      &#125;)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The above <code>env</code> block passes variables to be used by the test, which could be something like this:</p><figure class="highlight js"><figcaption><span>cypress/e2e/test.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;logs in&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;form&#x27;</span>)</span><br><span class="line">    .<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#username&#x27;</span>).<span class="title function_">type</span>(<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;username&#x27;</span>))</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#password&#x27;</span>).<span class="title function_">type</span>(<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;password&#x27;</span>))</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#submit&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    &#125;)</span><br><span class="line">  <span class="comment">// there should be user session on the backend</span></span><br><span class="line">  cy.<span class="title function_">task</span>(<span class="string">&#x27;checkUserSession&#x27;</span>, &#123;</span><br><span class="line">    <span class="attr">token</span>: <span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;apiToken&#x27;</span>),</span><br><span class="line">    <span class="attr">username</span>: <span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;username&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Ok, great, so where are these values &quot;username&quot;, &quot;password&quot;, and &quot;apiToken&quot;? Are they only in the Node process, the browser process, or both? In Cypress v15, these values are in both! Here is how you can visualize where the variables are:</p><p><img src="../images/cypress-expose/v15.png" alt="Cypress env variables are stored in both processes"></p><p>Hmm, ok, so the spec has all these values. What about the application under test? What about <em>every</em> 3rd-party script loaded by the web app under test? Turns out, every app under Cypress test can access <em>everything</em> inside the global <code>Cypress</code> object:</p><figure class="highlight js"><figcaption><span>app.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="variable language_">window</span>.<span class="property">Cypress</span>) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="variable language_">window</span>.<span class="property">Cypress</span>.<span class="title function_">env</span>())</span><br><span class="line">  <span class="comment">// &#123;</span></span><br><span class="line">  <span class="comment">//   &quot;username&quot;: &quot;joe...&quot;,</span></span><br><span class="line">  <span class="comment">//   &quot;password&quot;: &quot;123...&quot;,</span></span><br><span class="line">  <span class="comment">//   &quot;apiToken&quot;: &quot;abc...&quot;</span></span><br><span class="line">  <span class="comment">// &#125;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The above piece of code inside the website will print the username, the password, and the api token used by the test runner. Hmm, is this a good idea? I do not know why Cypress sets <code>window.Cypress</code> global object, it could simply set <code>window.Cypress = true</code> to let the app know that it is being tested, but there was no need to set the <em>entire</em> object.</p><p>Ok, so there are 3 different types of environment variables I see in our config file.</p><ul><li><code>username</code> is something the test <em>must</em> enter into the input field. It does not seem to be very secret, it could be a public value.</li><li><code>password</code> is something we probably want to hide from the application code, but something the spec running in the browser <em>needs</em> to get at some point.</li><li><code>apiToken</code> is NOT needed inside the browser, it could reside inside the Node.js Cypress task code. It should be kept secret from the web application, and sending it into the browser seems dangerous. Keep it inside the config Node.js process.</li></ul><h2><span id="refactor-for-safety">Refactor for safety</span></h2><p>Even in Cypress v15 we can use the following best practice for keeping secrets really secret:</p><figure class="highlight js"><figcaption><span>cypress.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;cypress&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">e2e</span>: &#123;</span><br><span class="line">    <span class="attr">env</span>: &#123;</span><br><span class="line">      <span class="attr">username</span>: <span class="string">&#x27;joe@acme.co&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">      <span class="title function_">on</span>(<span class="string">&#x27;task&#x27;</span>, &#123;</span><br><span class="line">        <span class="title function_">getSecret</span>(<span class="params">name</span>) &#123;</span><br><span class="line">          <span class="comment">// we assume that we pass all real secrets</span></span><br><span class="line">          <span class="comment">// via process.env properties</span></span><br><span class="line">          <span class="keyword">return</span> process.<span class="property">env</span>[name]</span><br><span class="line">        &#125;,</span><br><span class="line">        <span class="title function_">checkUserSession</span>(<span class="params">&#123; username &#125;</span>) &#123;</span><br><span class="line">          <span class="comment">// check DB using &#123; username, token: process.env.API_TOKEN &#125;</span></span><br><span class="line">        &#125;</span><br><span class="line">      &#125;)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Then the test can ask for the password value <em>when needed</em>, and never even ask for the &quot;apiToken&quot; value! The test can also &quot;ask&quot; for secrets using <code>cy.task(&#39;getSecret&#39;)</code> command, when needed. Here is the rewritten test.</p><figure class="highlight js"><figcaption><span>cypress/e2e/test.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;logs in&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;form&#x27;</span>)</span><br><span class="line">    .<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#username&#x27;</span>).<span class="title function_">type</span>(<span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;username&#x27;</span>))</span><br><span class="line">      cy.<span class="title function_">task</span>(<span class="string">&#x27;getSecret&#x27;</span>, <span class="string">&#x27;USER_PASSWORD&#x27;</span>)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="function"><span class="params">password</span> =&gt;</span> &#123;</span><br><span class="line">          cy.<span class="title function_">get</span>(<span class="string">&#x27;#password&#x27;</span>).<span class="title function_">type</span>(password)</span><br><span class="line">        &#125;)</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#submit&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    &#125;)</span><br><span class="line">  <span class="comment">// there should be user session on the backend</span></span><br><span class="line">  cy.<span class="title function_">task</span>(<span class="string">&#x27;checkUserSession&#x27;</span>, &#123;</span><br><span class="line">    <span class="attr">username</span>: <span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;username&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Just don&#39;t name any secrets using <code>CYPRESS_</code> prefix to avoid accidentally putting the value into <code>Cypress.env</code> object! We start Cypress with process environment variables set like this:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">USER_PASSWORD=secret! API_TOKEN=abc123$v101 cypress run</span><br></pre></td></tr></table></figure><blockquote class="pullquote"><p>💡 You can use my CLI utility <a href="https://github.com/bahmutov/as-a">as-a</a> to inject multiple blocks of environment variables at once.</p></blockquote><p>If an application tries to print <code>Cypress.env()</code> object, it sees just the &quot;username&quot;, since everything else is &quot;kept&quot; inside the Node.js process env object and not bundled into the browser spec.</p><p><img src="../images/cypress-expose/secrets.png" alt="Keep secrets in the process.env"></p><figure class="highlight js"><figcaption><span>app.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="variable language_">window</span>.<span class="property">Cypress</span>) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="variable language_">window</span>.<span class="property">Cypress</span>.<span class="title function_">env</span>())</span><br><span class="line">  <span class="comment">// &#123;</span></span><br><span class="line">  <span class="comment">//   &quot;username&quot;: &quot;joe...&quot;,</span></span><br><span class="line">  <span class="comment">// &#125;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="cypress-v16-cyenv-command">Cypress v16 cy.env command</span></h2><p>So here is the big change coming to Cypress v16: instead of writing custom task &quot;getSecret&quot;, there is a new command <code>cy.env</code> for retrieving the secret on demand. Your Cypress config file splits all variables into &quot;expose&quot; (public) and &quot;env&quot; (secrets) blocks:</p><figure class="highlight js"><figcaption><span>cypress.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;cypress&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Cypress v16</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">e2e</span>: &#123;</span><br><span class="line">    <span class="attr">expose</span>: &#123;</span><br><span class="line">      <span class="attr">username</span>: <span class="string">&#x27;joe@acme.co&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">env</span>: &#123;</span><br><span class="line">      <span class="attr">password</span>: <span class="string">&#x27;secret!&#x27;</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">      <span class="title function_">on</span>(<span class="string">&#x27;task&#x27;</span>, &#123;</span><br><span class="line">        <span class="title function_">checkUserSession</span>(<span class="params">&#123; username &#125;</span>) &#123;</span><br><span class="line">          <span class="comment">// check DB using &#123; username, token: process.env.API_TOKEN &#125;</span></span><br><span class="line">        &#125;</span><br><span class="line">      &#125;)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Do you need the username? You can get it immediately using the new <a href="https://on.cypress.io/expose">Cypress.expose</a> static method. You want to hear a secret? Call <code>cy.env</code> command.</p><figure class="highlight js"><figcaption><span>cypress/e2e/test.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> username = <span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;username&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;logs in&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;form&#x27;</span>)</span><br><span class="line">    .<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#username&#x27;</span>).<span class="title function_">type</span>(username)</span><br><span class="line">      cy.<span class="title function_">env</span>([<span class="string">&#x27;password&#x27;</span>])</span><br><span class="line">        .<span class="title function_">then</span>(<span class="function">(<span class="params">&#123; password &#125;</span>) =&gt;</span> &#123;</span><br><span class="line">          cy.<span class="title function_">get</span>(<span class="string">&#x27;#password&#x27;</span>).<span class="title function_">type</span>(password)</span><br><span class="line">        &#125;)</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#submit&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    &#125;)</span><br><span class="line">  <span class="comment">// there should be user session on the backend</span></span><br><span class="line">  cy.<span class="title function_">task</span>(<span class="string">&#x27;checkUserSession&#x27;</span>, &#123;</span><br><span class="line">    username</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Any process env values that start with <code>CYPRESS_</code> prefix are automatically set to the <code>env</code> block, so they are considered secrets. So you can overwrite the password specified in the Cypress config file:</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_"># </span><span class="language-bash">overwrite using CYPRESS_ <span class="built_in">env</span> variable</span></span><br><span class="line">CYPRESS_password=newSecret! npx cypress run</span><br><span class="line"><span class="meta prompt_"># </span><span class="language-bash">overwrite using --<span class="built_in">env</span> CLI</span></span><br><span class="line">npx cypress run --env password=newSecret!</span><br></pre></td></tr></table></figure><p>You can also overwrite the non-secret values using the new CLI option <code>--expose</code> (there is no process env setting to overwrite an exposed value)</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npx cypress run --expose username=gb@acme.com --env password=gbPass</span><br></pre></td></tr></table></figure><p>Migrating from <code>Cypress.env</code> for everything might take some time. I have a few plugins that I am migrating right now, but overall it is not too bad. Just think what you <em>really</em> need for your test, and if the values are secrets to be used in the browser vs secrets that should <em>never</em> go into the browser.</p><ul><li>For non-secret values, use the <code>expose</code> object and the <code>Cypress.expose</code> static method.</li><li>For secrets needed by the browser test, use the <code>cy.env</code> command to get the value when needed</li><li>For &quot;background&quot; secrets that are not meant to be used inside the browser at all, use the <code>process.env</code> object and access from the Cypress config and the <code>setupNodeEvents</code> callback</li></ul><p>Stay safe out there!</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Cypress &lt;a href=&quot;https://on.cypress.io/changelog#15-10-0&quot;&gt;v15.10.0&lt;/a&gt; has announced a big switch coming in v16 - the new way of dealing 
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
  </entry>
  
  <entry>
    <title>How To Publish To NPM From GitHub Actions</title>
    <link href="https://glebbahmutov.com/blog/npm-publish-from-github/"/>
    <id>https://glebbahmutov.com/blog/npm-publish-from-github/</id>
    <published>2026-02-23T05:00:00.000Z</published>
    <updated>2026-03-12T02:40:39.543Z</updated>
    
    <content type="html"><![CDATA[<p>At the end of 2025, NPM registry <a href="https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available/">revoked all personal NPM tokens</a> that I used to <a href="/blog/how-i-publish-to-npm/" title="publish new NPM package releases">publish new NPM package releases</a>. This change improves the security of the entire NPM publishing workflow, but has disrupted my CI process. For example, the new feature of <a href="https://github.com/bahmutov/cypress-timestamps">cypress-timestamps</a> has not been released, failing with the error &quot;SemanticReleaseError: Invalid npm token.&quot;. Hmm, what do we do now?</p><p><img src="../images/npm-publish-from-github/error.png" alt="NPM publishing error: invalid token"></p><p>We could use publish to NPM using local <code>npm</code> CLI commands, entering the 2FA token, etc. But I really <em>hate</em> this idea. I have more than <a href="https://www.npmjs.com/~bahmutov">400 NPM packages</a>, so the release process MUST be automated and be performed by CI. So let&#39;s look at the <a href="https://docs.npmjs.com/trusted-publishers">trusted publishing</a> where NPM &quot;knows&quot; that a particular workflow from GitHub Actions is allowed to publish (and no one else). I already use trusted publishing to publish NPM packages <a href="https://github.com/bahmutov/cypress-split">cypress-split</a> and <a href="https://github.com/bahmutov/cypress-map">cypress-map</a>, so it should be simple to apply the same steps to <code>cypress-timestamps</code>.</p><blockquote class="pullquote"><p>📺 You can watch the steps from this blog post explained in <a href="https://youtu.be/MGDRAOOOOuc">this video</a> I recorded after writing the blog post.</p></blockquote><p>First, go to the package settings under your NPM registry account.</p><p><img src="../images/npm-publish-from-github/package.png" alt="cypress-timestamps settings on NPM registry"></p><p>The top settings section is for configuring the trusted publishing. Seems both GitHub Actions and GitLab CI providers can be configured as of this writing (February 2026). I am using GHA, so I will click &quot;GitHub Actions&quot; button.</p><p><img src="../images/npm-publish-from-github/trusted-publishing.png" alt="Package settings includes trusted publishing config"></p><blockquote class="pullquote"><p>⚠️ Setting up trusted publishing requires an existing NPM package, thus the very first version should be published from your terminal command line using the <code>npm publish</code> command. If this is the very first package version, it might not be even scraped yet, so NPM search returns nothing. In that case, simply go to the last packages page on your profile to find the newly created package.</p></blockquote><p>Enter the GitHub username (organization name) and the repository name, and the name of the workflow file (inside the <code>.github/workflows</code> folder). In my case, I am pointing at the <a href="https://github.com/bahmutov/cypress-timestamps/blob/main/.github/workflows/ci.yml">ci.yml</a> file.</p><p><img src="../images/npm-publish-from-github/config.png" alt="Tell NPM registry where the GHA workflow for the package is"></p><p>Enter the 2FA token if needed</p><p><img src="../images/npm-publish-from-github/otp.png" alt="2FA is needed to connect GHA to NPM registry"></p><p>You should see the &quot;success&quot; banner.</p><p><img src="../images/npm-publish-from-github/success.png" alt="Trusted publishing has been configured"></p><p>Great, let&#39;s now update the <a href="https://github.com/bahmutov/cypress-timestamps/blob/main/.github/workflows/ci.yml">ci.yml</a> file. We can remove the old NPM token and bump the semantic release. For clarity, I added comments to the workflow code to explain each step</p><figure class="highlight yml"><figcaption><span>ci.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">ci</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">test:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">permissions:</span></span><br><span class="line">      <span class="comment"># allow the release step to comment on</span></span><br><span class="line">      <span class="comment"># issues, pull requests, and write to the repo</span></span><br><span class="line">      <span class="attr">contents:</span> <span class="string">write</span></span><br><span class="line">      <span class="attr">issues:</span> <span class="string">write</span></span><br><span class="line">      <span class="attr">pull-requests:</span> <span class="string">write</span></span><br><span class="line">      <span class="comment"># Required for OIDC to let NPM registry know</span></span><br><span class="line">      <span class="comment"># that this workflow is trusted</span></span><br><span class="line">      <span class="attr">id-token:</span> <span class="string">write</span></span><br><span class="line"></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="comment"># important: the default Node version is v18</span></span><br><span class="line">      <span class="comment"># which gives us an auth error trying to release</span></span><br><span class="line">      <span class="comment"># so install v24 explicitly</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/setup-node@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">node-version:</span> <span class="number">24</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">tests</span> <span class="string">🧪</span></span><br><span class="line">        <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Check</span> <span class="string">type</span> <span class="string">declarations</span> <span class="string">🔎</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">typecheck</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Semantic</span> <span class="string">Release</span> <span class="string">🚀</span></span><br><span class="line">        <span class="comment"># https://github.com/cycjimmy/semantic-release-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cycjimmy/semantic-release-action@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">branch:</span> <span class="string">main</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">GITHUB_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>I try to limit the <code>GITHUB_TOKEN</code> permissions to each job, if possible. The important GHA to NPM registry bit is the <code>id-token: write</code> that lets NPM &quot;know&quot; that this CI workflow is legit and is allowed to publish new NPM versions. We can see the successful GHA workflow</p><p><img src="../images/npm-publish-from-github/gha.png" alt="Successful CI workflow including the release step"></p><p>We can see the new package version on NPM</p><p><img src="../images/npm-publish-from-github/new-version.png" alt="NPM version v1.5.0 has been published"></p><p>Nice.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;At the end of 2025, NPM registry &lt;a href=&quot;https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="github" scheme="https://glebbahmutov.com/blog/tags/github/"/>
    
      <category term="modular development" scheme="https://glebbahmutov.com/blog/tags/modular-development/"/>
    
  </entry>
  
  <entry>
    <title>Check Every Box In Cypress Tests Without Flake</title>
    <link href="https://glebbahmutov.com/blog/check-every-box/"/>
    <id>https://glebbahmutov.com/blog/check-every-box/</id>
    <published>2026-02-11T05:00:00.000Z</published>
    <updated>2026-04-08T13:25:42.376Z</updated>
    
    <content type="html"><![CDATA[<p>Imagine you are testing a TodoMVC application, and you need to complete all items. You simply click every checkbox and confirm the application preserves the &quot;0 todos left&quot; state. Normally everything goes well:</p><p><img src="../images/check-every-box/well.png" alt="Successful test with zero todos left"></p><p>But sometimes a weird thing happens: one or more checkboxes remain unchecked!</p><p><img src="../images/check-every-box/unwell.png" alt="On this rare occasion the test has failed to check all boxes"></p><p>📺 Watch the examples in this blog post explained in <a href="https://youtu.be/I6d2vV8zahg">my video</a>.</p><p>You start looking at the test code. Looks ok, right?</p><figure class="highlight js"><figcaption><span>cypress/e2e/todos.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line">  <span class="comment">// complete all todos by clicking the checkboxes</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">click</span>(&#123; <span class="attr">multiple</span>: <span class="literal">true</span> &#125;)</span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><blockquote class="pullquote"><p>This test starts by creating a random number of Todo items, something I covered in the &quot;Cypress Vs Playwright&quot; online course available at <a href="https://cypress.tips/courses">cypress.tips&#x2F;courses</a>.</p></blockquote><p>Ok, there seems to be some flake, and we know how to solve <a href="/blog/flakiness-example/" title="end-to-end testing flake">end-to-end testing flake</a> pretty <a href="/blog/cypress-flaky-tests-exercises/" title="well">well</a>. We start adding assertions, trying to confirm that our commands have finished successfully before reloading the page.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line">  <span class="comment">// complete all todos by clicking the checkboxes</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">click</span>(&#123; <span class="attr">multiple</span>: <span class="literal">true</span> &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are done</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>We inserted the &quot;0 todos&quot; check before the <code>cy.reload</code> command, and ... it did not solve the problem!</p><p><img src="../images/check-every-box/still-unwell.png" alt="The test still fails on occasion"></p><p>What is happening, how can the same assertion pass once, but then immediately fail? The clue is in the number of network calls shown int the Command Log. In this instance, we have 4 todos to complete. Yet, there are only 3 &quot;PATCH&quot; network calls!</p><p><img src="../images/check-every-box/3-calls.png" alt="We clicked 4 checkboxes, but saw 3 network calls"></p><p>This is typical web app behavior: update the local state immediately, and send the update to the backend. But what happens if the app does not have time to send the network call <em>before</em> the page reloads? The network call does not happen, and the backend does not &quot;see&quot; one or more &quot;PATCH&quot; network calls. Checking just the page UI using <code>cy.get(&#39;[data-cy=&quot;remaining-count&quot;]&#39;).should(&#39;have.text&#39;, &#39;0&#39;)</code> does not solve the problem: the problem is that the backend still has not been updated.</p><p>There is no way for a testing framework to &quot;know&quot; that the application has scheduled <code>setTimeout</code> to make a network call and &quot;wait&quot; for it. Well, you could have some flag or global queue of timers in your app, and Cypress <em>could</em> look it up. But 99.99% of web apps would not do this overhead just for testing, so we have to work with what we got. Luckily, the problem is pretty manageable.</p><p>We can solve this issue in several ways.</p><h2><span id="check-the-network-call-count">Check the network call count</span></h2><p>Using the <a href="https://cypress.tips/courses/network-testing">incredibly powerful</a> <code>cy.intercept</code> command, we can &quot;continue&quot; the test after N network calls happen.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes (N network calls)&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">intercept</span>(<span class="string">&#x27;PATCH&#x27;</span>, <span class="string">&#x27;/todos/*&#x27;</span>).<span class="title function_">as</span>(<span class="string">&#x27;updateTodo&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// complete all todos by clicking the checkboxes</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">click</span>(&#123; <span class="attr">multiple</span>: <span class="literal">true</span> &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all network calls have finished</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;@updateTodo.all&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>We spy on the <code>PATCH /todos/*</code> calls using the <code>cy.intercept</code> command before we start clicking. Then we check the count of intercepts calls using the <code>cy.get(&#39;@updateTodo.all&#39;).should(&#39;have.length&#39;, this.n)</code> command and assertion combo, which retries. Even if the app takes a few seconds to fire the network call, the test will be flake-free.</p><p><img src="../images/check-every-box/spy.png" alt="The test waits even if network calls are delayed"></p><h2><span id="observe-each-network-call">Observe each network call</span></h2><p>Instead of using <code>.click(&#123; multiple: true &#125;)</code>, we could click each box individually and confirm its network calls finished. I like this approach even more than the &quot;click multiple elements&quot; option, since it works for <em>any</em> action, not just <code>cy.click</code>, For example, let&#39;s use <a href="https://on.cypress.io/check">cy.check</a> command, which does not have <code>multiple: true</code> option.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes (one at a time)&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">intercept</span>(<span class="string">&#x27;PATCH&#x27;</span>, <span class="string">&#x27;/todos/*&#x27;</span>).<span class="title function_">as</span>(<span class="string">&#x27;updateTodo&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">each</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">wrap</span>($el, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;).<span class="title function_">check</span>()</span><br><span class="line">    <span class="comment">// confirm a single network call has finished</span></span><br><span class="line">    cy.<span class="title function_">wait</span>(<span class="string">&#x27;@updateTodo&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>We are using <a href="https://on.cypress.io/each">cy.each</a> command. It gives us each element as a jQuery object. To properly click it using Cypress, simply <a href="https://on.cypress.io/wrap">cy.wrap</a> silently to avoid Command Log noise and run the <code>cy.check</code>, followed by <a href="https://on.cypress.io/wait">cy.wait</a> command with a network alias - it waits for 1 network call. If you want to confirm the call was successful, add an assertion.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">each</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">wrap</span>($el, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;).<span class="title function_">check</span>()</span><br><span class="line">  <span class="comment">// confirm a single network call has finished successfully</span></span><br><span class="line">  cy.<span class="title function_">wait</span>(<span class="string">&#x27;@updateTodo&#x27;</span>)</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;response&#x27;</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;have.property&#x27;</span>, <span class="string">&#x27;statusCode&#x27;</span>, <span class="number">200</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2><span id="ask-the-server">Ask the server</span></h2><p>If we are not sure when the application has finished updating the backend, why not ask the backend directly? We can ping the backend ourselves, just like the app does using the <a href="https://on.cypress.io/request">cy.request</a> command. To ping the server multiple times until all todos are completed, we can use my plugin <a href="https://github.com/bahmutov/cypress-recurse">cypress-recurse</a>:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; recurse &#125; <span class="keyword">from</span> <span class="string">&#x27;cypress-recurse&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes (check the backend)&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>).<span class="title function_">each</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">wrap</span>($el, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;).<span class="title function_">check</span>()</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm the backend has only completed todos</span></span><br><span class="line">  <span class="title function_">recurse</span>(</span><br><span class="line">    <span class="function">() =&gt;</span> cy.<span class="title function_">request</span>(<span class="string">&#x27;/todos&#x27;</span>).<span class="title function_">its</span>(<span class="string">&#x27;body&#x27;</span>),</span><br><span class="line">    <span class="function">(<span class="params">todos</span>) =&gt;</span> todos.<span class="title function_">every</span>(<span class="function">(<span class="params">todo</span>) =&gt;</span> todo.<span class="property">completed</span>),</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">log</span>: <span class="string">&#x27;All todos are completed on the server&#x27;</span>,</span><br><span class="line">      <span class="attr">timeout</span>: <span class="number">30_000</span>,</span><br><span class="line">      <span class="attr">delay</span>: <span class="number">1000</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">  )</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The important part is the <code>recurse</code> call with two functions: fetching the list of todos and the predicate to know when to stop pinging:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">recurse</span>(</span><br><span class="line">  <span class="function">() =&gt;</span> cy.<span class="title function_">request</span>(<span class="string">&#x27;/todos&#x27;</span>).<span class="title function_">its</span>(<span class="string">&#x27;body&#x27;</span>), <span class="comment">// produce the values</span></span><br><span class="line">  <span class="function">(<span class="params">todos</span>) =&gt;</span> todos.<span class="title function_">every</span>(<span class="function">(<span class="params">todo</span>) =&gt;</span> todo.<span class="property">completed</span>), <span class="comment">// check the value</span></span><br><span class="line">  <span class="comment">// options: how many times to check, how long to wait between the checks, etc</span></span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">log</span>: <span class="string">&#x27;All todos are completed on the server&#x27;</span>,</span><br><span class="line">    <span class="attr">timeout</span>: <span class="number">30_000</span>,</span><br><span class="line">    <span class="attr">delay</span>: <span class="number">1000</span>,</span><br><span class="line">  &#125;,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>The <code>recurse</code> calls the first function repeatedly, passes the yielded value into the predicate, and stops the iteration when the predicate returns a truthy value. We use 1 second delays for clarity, and I slowed down the web app to space out network calls. You can see several <code>REQUEST /todos</code> commands - this is our check working</p><p><img src="../images/check-every-box/recurse.png" alt="Checking the backend"></p><h2><span id="map-chain">Map chain</span></h2><p>Finally, what if you want to check the list of updated todos? We need their ids, so our iteration must produce a list of numbers. But <code>cy.each</code> yields the original list of elements, not a custom value.</p><p>Plugin <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> to the rescue! It has <code>cy.mapChain</code> command where you can do something with each element, but then produce <em>new</em> values for the next command in the chain. Once we get all completed ids, we can compare it with the list from the <code>beforeEach</code> where we saved the created todo ids</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-map&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">beforeEach</span>(<span class="keyword">function</span> <span class="title function_">createRandomTodos</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> n = <span class="title class_">Cypress</span>.<span class="property">_</span>.<span class="title function_">random</span>(<span class="number">1</span>, <span class="number">3</span>)</span><br><span class="line">  ...</span><br><span class="line">  <span class="comment">// save created ids for later</span></span><br><span class="line">  cy.<span class="title function_">wrap</span>(todos.<span class="title function_">map</span>(<span class="function">(<span class="params">t</span>) =&gt;</span> t.<span class="property">id</span>)).<span class="title function_">as</span>(<span class="string">&#x27;ids&#x27;</span>)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;are completed by checking the boxes (collect their ids)&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> todos = <span class="string">&#x27;.todo-list li&#x27;</span></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(todos).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="variable language_">this</span>.<span class="property">n</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">intercept</span>(<span class="string">&#x27;PATCH&#x27;</span>, <span class="string">&#x27;/todos/*&#x27;</span>).<span class="title function_">as</span>(<span class="string">&#x27;updateTodo&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li .toggle&#x27;</span>)</span><br><span class="line">    .<span class="title function_">mapChain</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">wrap</span>($el, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;).<span class="title function_">check</span>()</span><br><span class="line">      <span class="comment">// from each network call, grab the id of the updated todo</span></span><br><span class="line">      cy.<span class="title function_">wait</span>(<span class="string">&#x27;@updateTodo&#x27;</span>).<span class="title function_">its</span>(<span class="string">&#x27;response.body.id&#x27;</span>)</span><br><span class="line">    &#125;)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;deep.equal&#x27;</span>, <span class="variable language_">this</span>.<span class="property">ids</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// confirm all todos are marked as completed</span></span><br><span class="line">  <span class="comment">// after reloading the page</span></span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.loaded&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;remaining-count&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The command <code>.mapChain(fn)</code> runs the commands inside the function and collects all yielded values into an array that it will yield to the next assertion. Thus our code works, here is the relevant part:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">.<span class="title function_">mapChain</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">wrap</span>($el, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;).<span class="title function_">check</span>()</span><br><span class="line">  <span class="comment">// from each network call, grab the id of the updated todo</span></span><br><span class="line">  cy.<span class="title function_">wait</span>(<span class="string">&#x27;@updateTodo&#x27;</span>).<span class="title function_">its</span>(<span class="string">&#x27;response.body.id&#x27;</span>)</span><br><span class="line">&#125;)</span><br><span class="line">.<span class="title function_">should</span>(<span class="string">&#x27;deep.equal&#x27;</span>, <span class="variable language_">this</span>.<span class="property">ids</span>)</span><br></pre></td></tr></table></figure><p>You can see the original ids in the &quot;BEFORE EACH&quot; hook, and you can see the array assertion inside the test; these are the same ids, and the order is correct.</p><p><img src="../images/check-every-box/map-chain.png" alt="Confirming the ID value for each completed todo item"></p><p>Nice. I must say these tests and how they interact with this example application are better shown than explained in a blog post. I will record a video showing these tests in action and will post on my <a href="https://youtube.com/@gleb">youtube.com&#x2F;@gleb</a> channel, stay tuned.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Imagine you are testing a TodoMVC application, and you need to complete all items. You simply click every checkbox and confirm the applic
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
  </entry>
  
  <entry>
    <title>Cypress Dependencies Through A Docker Image</title>
    <link href="https://glebbahmutov.com/blog/cypress-dependencies-through-docker-image/"/>
    <id>https://glebbahmutov.com/blog/cypress-dependencies-through-docker-image/</id>
    <published>2026-01-08T05:00:00.000Z</published>
    <updated>2026-02-03T14:57:28.234Z</updated>
    
    <content type="html"><![CDATA[<p>If you are testing a website, the DEV dependencies do not change very often. You might bump Cypress version once in a while, add or upgrade a <a href="/blog/how-to-pick-cypress-plugins/" title="Cypress plugin">Cypress plugin</a>, but in general the Node dependencies are changed less frequently than the spec files. Thus running <code>npm ci</code> on every test job is bound to be repetitive work that slows down your testing pipelines.</p><p>Of course, each CI allows you to cache dependencies between the jobs - but why do you need to even think about it? You want to cache all dependencies the very first time <code>npm ci</code> runs for the given <code>package.json</code> or <code>package-lock.json</code> file, not the first time each the workflow runs! Most CIs let you control the container image used for running tests; I assume you use one of <a href="https://github.com/cypress-io/cypress-docker-images">Cypress Docker images</a> or one of Cypress Docker images with <a href="https://hub.docker.com/r/cypress/browsers/tags">browsers installed</a>. So what if we could create <em>our own</em> Docker image based on Cypress (or any appropriate Docker base image you might want) and run <code>npm ci</code> in that image, and then use <strong>that</strong> Docker image to simply run our current tests?</p><p>I had the idea of caching separately PROD and DEV images inside Docker containers a loooong time ago, see the <a href="https://github.com/bahmutov/double-docker">bahmutov&#x2F;double-docker</a> repo. In this blog post, I will show a simple approach that works for repositories with tests only, the situation described in the blog post <a href="/blog/two-repo-github-actions-setup/" title="Separate Application And Tests Repos GitHub Actions Setup">Separate Application And Tests Repos GitHub Actions Setup</a>. We will use GitHub Actions and the public Docker registry.</p><p>In the nutshell:</p><ul><li>whenever the user changes <code>package.json</code> or <code>Dockerfile</code>, we build a new Docker image with the <code>node_modules</code> and Cypress binary folders (but no other source code)</li><li>we push the built Docker image to the registry</li><li>every CI run simply pulls that Docker image and checks out the tests into the container</li><li>the tests run immediately after the checkout step, since there is nothing to install</li></ul><p>Let&#39;s see the code</p><!-- toc --><ul><li><a href="#dockerfile">Dockerfile</a></li><li><a href="#workflow">Workflow</a><ul><li><a href="#build-and-push">Build and push</a></li><li><a href="#test">Test</a></li></ul></li><li><a href="#run-on-github-actions">Run on GitHub Actions</a></li><li><a href="#using-the-cypress-action">Using The Cypress Action</a></li><li><a href="#github-container-registry">GitHub Container Registry</a></li><li><a href="#google-artifact-registry">Google Artifact Registry</a></li></ul><!-- tocstop --><blockquote class="pullquote"><p>🎁 I have the working example in the public repo <a href="https://github.com/bahmutov/cypress-tests-image">bahmutov&#x2F;cypress-tests-image</a>.</p></blockquote><h2><span id="dockerfile">Dockerfile</span></h2><p>First, let&#39;s look a the Dockerfile</p><figure class="highlight yml"><figcaption><span>Dockerfile</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># pick the image to build from</span></span><br><span class="line"><span class="comment"># either the base or the browsers image</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># https://hub.docker.com/r/cypress/base/tags</span></span><br><span class="line"><span class="string">FROM</span> <span class="string">cypress/base:24.12.0</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># https://hub.docker.com/r/cypress/browsers/tags</span></span><br><span class="line"><span class="comment"># FROM cypress/browsers:node-24.12.0-chrome-143.0.7499.169-1-ff-146.0.1-edge-143.0.3650.96-1</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># diagnostics</span></span><br><span class="line"><span class="string">RUN</span> <span class="string">echo</span> <span class="string">&quot;node -v&quot;</span></span><br><span class="line"><span class="string">RUN</span> <span class="string">echo</span> <span class="string">&quot;npm -v&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># copy ONLY the package.json and package-lock.json files</span></span><br><span class="line"><span class="string">WORKDIR</span> <span class="string">/e2e</span></span><br><span class="line"><span class="string">COPY</span> <span class="string">package.json</span> <span class="string">package-lock.json</span> <span class="string">./</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># install npm dependencies</span></span><br><span class="line"><span class="comment"># and put the Cypress binary in the local subfolder</span></span><br><span class="line"><span class="comment"># https://on.cypress.io/installation</span></span><br><span class="line"><span class="string">ENV</span> <span class="string">CYPRESS_CACHE_FOLDER=/e2e/cypress_cache</span></span><br><span class="line"><span class="string">RUN</span> <span class="string">npm</span> <span class="string">ci</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># verify Cypress installation</span></span><br><span class="line"><span class="string">RUN</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">verify</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># the Docker image should have all Cypress OS dependencies installed</span></span><br><span class="line"><span class="comment"># plus inside the &quot;/e2e&quot; folder</span></span><br><span class="line"><span class="comment"># we will have</span></span><br><span class="line"><span class="comment">#   - node_modules with Cypress installed</span></span><br><span class="line"><span class="comment">#   - cypress_cache with the Cypress binary</span></span><br></pre></td></tr></table></figure><p><strong>Tip:</strong> you can use <code>yarn.lock</code> or any other lock file with this approach.</p><p>I am naming my image <code>bahmutov/cy:&lt;tag&gt;</code>, you can find them at the <a href="https://hub.docker.com/repository/docker/bahmutov/cy/tags">Docker hub</a>. Note that it is important to build the image on the same OS architecture as the CI machines, in my case it will be <code>ubuntu-latest</code>.</p><h2><span id="workflow">Workflow</span></h2><p>The first step in the workflow computes the combined checksum of <code>package.json</code> and <code>Dockerfile</code> files. It also checks if the Docker image tagged with this checksum exists already using action <a href="https://github.com/tyriis/docker-image-tag-exists">tyriis&#x2F;docker-image-tag-exists</a>.</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">CI</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="comment"># computes the hash of package.json and and stores it in the output</span></span><br><span class="line">  <span class="comment"># also checks if the Docker image with this tag already exists</span></span><br><span class="line">  <span class="comment"># outputs:</span></span><br><span class="line">  <span class="comment">#   hash: the package.json hash</span></span><br><span class="line">  <span class="comment">#   tag: whether the Docker image with this tag already exists, &quot;found&quot; or &quot;not found&quot;</span></span><br><span class="line">  <span class="attr">package-hash:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">outputs:</span></span><br><span class="line">      <span class="attr">hash:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.tag-exists.outputs.tag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="comment"># only needed to get package.json and Dockerfile to compute the hash</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">sparse-checkout:</span> <span class="string">|</span></span><br><span class="line"><span class="string">            package.json</span></span><br><span class="line"><span class="string">            Dockerfile</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Package.json</span> <span class="string">+</span> <span class="string">Dockerfile</span> <span class="string">checksum</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">hash</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">echo</span> <span class="string">&quot;checksum=$<span class="template-variable">&#123;&#123; hashFiles(&#x27;package.json&#x27;, &#x27;Dockerfile&#x27;) &#125;&#125;</span>&quot;</span> <span class="string">&gt;&gt;</span> <span class="string">$GITHUB_OUTPUT</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># https://github.com/tyriis/docker-image-tag-exists</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Check</span> <span class="string">if</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">tag</span> <span class="string">exists</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">tag-exists</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">tyriis/docker-image-tag-exists@v2.1.0</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">docker.io</span></span><br><span class="line">          <span class="attr">repository:</span> <span class="string">bahmutov/cy</span></span><br><span class="line">          <span class="comment"># The container image tag</span></span><br><span class="line">          <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Report</span> <span class="string">the</span> <span class="string">check</span> <span class="string">results</span></span><br><span class="line">        <span class="comment"># print the tag result into Github Actions summary</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;## Docker image check&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Package.json + Dockerfile hash: $&#123;&#123; steps.hash.outputs.checksum &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Docker image bahmutov/cy:$&#123;&#123; steps.hash.outputs.checksum &#125;&#125; **$&#123;&#123; steps.tag-exists.outputs.tag &#125;&#125;**&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br></pre></td></tr></table></figure><h3><span id="build-and-push">Build and push</span></h3><p>Great, let&#39;s build the Docker image if one is missing. We could have a job with every internal step using a condition:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">build-docker-image:</span></span><br><span class="line">  <span class="comment"># builds the Docker image and pushes it to the Docker hub</span></span><br><span class="line">  <span class="comment"># but only if it does not exist yet</span></span><br><span class="line">  <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">  <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">  <span class="attr">steps:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">      <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">      <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Log</span> <span class="string">in</span> <span class="string">to</span> <span class="string">Docker</span> <span class="string">Hub</span></span><br><span class="line">      <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="comment"># https://github.com/docker/login-action</span></span><br><span class="line">      <span class="attr">uses:</span> <span class="string">docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a</span></span><br><span class="line">      <span class="string">...</span></span><br></pre></td></tr></table></figure><p>But I think there is a slightly nicer way. We can control the entire job by the <code>needs.package-hash.outputs.tag</code> value and skip it, if the image exists:</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">CI</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="comment"># computes the hash of package.json and and stores it in the output</span></span><br><span class="line">  <span class="comment"># also checks if the Docker image with this tag already exists</span></span><br><span class="line">  <span class="comment"># outputs:</span></span><br><span class="line">  <span class="comment">#   hash: the package.json hash</span></span><br><span class="line">  <span class="comment">#   tag: whether the Docker image with this tag already exists, &quot;found&quot; or &quot;not found&quot;</span></span><br><span class="line">  <span class="attr">package-hash:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">outputs:</span></span><br><span class="line">      <span class="attr">hash:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.tag-exists.outputs.tag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="string">...</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">build-docker-image:</span></span><br><span class="line">    <span class="comment"># builds the Docker image and pushes it to the Docker hub</span></span><br><span class="line">    <span class="comment"># but only if it does not exist yet</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Log</span> <span class="string">in</span> <span class="string">to</span> <span class="string">Docker</span> <span class="string">Hub</span></span><br><span class="line">        <span class="comment"># https://github.com/docker/login-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">username:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.DOCKER_USERNAME</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.DOCKER_PASSWORD</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># we could also use the action</span></span><br><span class="line">      <span class="comment"># https://github.com/docker/build-push-action</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Build</span> <span class="string">docker</span> <span class="string">image</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">build</span> <span class="string">-t</span> <span class="string">bahmutov/cy:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span> <span class="string">.</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Push</span> <span class="string">Docker</span> <span class="string">images</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">push</span> <span class="string">bahmutov/cy:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><h3><span id="test">Test</span></h3><p>Now that we have Docker image build (if needed), let&#39;s use it. We can define another job that simply uses the <code>bahmutov/cy:&lt;tag&gt;</code> container, but it cannot depend on the <code>build-docker-image</code> job - if the job is skipped on GitHub Actions, any job that depends on it will be skipped too. Luckily, there is an easy fix: simply have yet <em>another</em> job that will simply &quot;ping&quot; <code>build-docker-image</code> job status. Once the <code>build-docker-image</code> job finishes or is skipped, our &quot;ping&quot; job resolves. The &quot;ping&quot; is done using <a href="https://github.com/lewagon/wait-on-check-action">lewagon&#x2F;wait-on-check-action</a> 3rd-party action. Here is how the last part of the workflow looks:</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">CI</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="comment"># computes the hash of package.json and and stores it in the output</span></span><br><span class="line">  <span class="comment"># also checks if the Docker image with this tag already exists</span></span><br><span class="line">  <span class="comment"># outputs:</span></span><br><span class="line">  <span class="comment">#   hash: the package.json hash</span></span><br><span class="line">  <span class="comment">#   tag: whether the Docker image with this tag already exists, &quot;found&quot; or &quot;not found&quot;</span></span><br><span class="line">  <span class="attr">package-hash:</span></span><br><span class="line">    <span class="string">...</span></span><br><span class="line">  <span class="attr">build-docker-image:</span></span><br><span class="line">    <span class="comment"># builds the Docker image and pushes it to the Docker hub</span></span><br><span class="line">    <span class="comment"># but only if it does not exist yet</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="string">...</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">wait-for-build:</span></span><br><span class="line">    <span class="comment"># a trick to allow other jobs to run, even if the &quot;build&quot; job is skipped</span></span><br><span class="line">    <span class="comment"># runs in parallel with the &quot;build&quot; job and keeps checking if it is finished</span></span><br><span class="line">    <span class="comment"># or is skipped</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Wait</span> <span class="string">for</span> <span class="string">the</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">build</span> <span class="string">/</span> <span class="string">skip</span></span><br><span class="line">        <span class="comment"># https://github.com/lewagon/wait-on-check-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">lewagon/wait-on-check-action@v1.4.1</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">ref:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.ref</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">check-name:</span> <span class="string">build-docker-image</span></span><br><span class="line">          <span class="attr">repo-token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># seconds between checks</span></span><br><span class="line">          <span class="attr">wait-interval:</span> <span class="number">10</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">test:</span></span><br><span class="line">    <span class="comment"># this job finishes after the Docker image is built (or exists already)</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> [<span class="string">package-hash</span>, <span class="string">wait-for-build</span>]</span><br><span class="line">    <span class="attr">container:</span> <span class="string">bahmutov/cy:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># THE IMPORTANT STEP: symlink the node modules</span></span><br><span class="line">      <span class="comment"># from the Docker image into the working folder</span></span><br><span class="line">      <span class="comment"># so we can skip the installation step</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Symlink</span> <span class="string">node</span> <span class="string">modules</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">ln</span> <span class="string">-s</span> <span class="string">/e2e/node_modules</span> <span class="string">./node_modules</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">Cypress</span> <span class="string">version</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">--version</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">Cypress</span> <span class="string">tests</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">run</span></span><br></pre></td></tr></table></figure><p>The sym linking step is very important. After checkout finishes, the container has the following code</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">/e2e/</span><br><span class="line">  cypress_cache/</span><br><span class="line">  node_modules/</span><br><span class="line">/&lt;folder with checked out source code&gt;/</span><br><span class="line">  cypress/</span><br><span class="line">  cypress.conf.js</span><br><span class="line">  package.json</span><br><span class="line">  package-lock.json</span><br></pre></td></tr></table></figure><p>The current directory is set to <code>/&lt;folder with checked out source code&gt;</code>, so we need to make sure Node and Cypress can find the DEV dependencies. We do it by creating the symlink:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">/e2e/</span><br><span class="line">  cypress_cache/</span><br><span class="line">  node_modules/</span><br><span class="line">/&lt;folder with checked out source code&gt;/</span><br><span class="line">  node_modules -&gt; /e2e/node_modules/</span><br><span class="line">  cypress/</span><br><span class="line">  cypress.conf.js</span><br><span class="line">  package.json</span><br><span class="line">  package-lock.json</span><br></pre></td></tr></table></figure><p>We tell Cypress NPM module to find its binary in the <code>/e2e/cypress_cache/</code> folder by using the environment variable baked into the Dockerfile: <code>ENV CYPRESS_CACHE_FOLDER=/e2e/cypress_cache</code>.</p><h2><span id="run-on-github-actions">Run on GitHub Actions</span></h2><p>Let&#39;s confirm that it works. We can push the code for the very first time, or change <code>package.json</code> or <code>Dockerfile</code></p><p><img src="../images/cypress-dependencies-through-docker-image/build.png" alt="The Docker image is built and pushed"></p><p>The build and push step took 1m14s, and the &quot;ping&quot; job that checked the job status every ten seconds finished in 1m22s. The test job simply pulled the Docker container and ran the specs, without any additional installation or resting a cache.</p><p><img src="../images/cypress-dependencies-through-docker-image/test-steps.png" alt="Test job steps"></p><p>Pulling the container from the Docker image is by far the longest step in the job.</p><p>Now let&#39;s push another commit - maybe we changed the specs, but haven&#39;t touched the <code>package.json</code> or <code>Dockerfile</code>. The Docker image should have been found.</p><p><img src="../images/cypress-dependencies-through-docker-image/found.png" alt="The workflow when the Docker image with DEV dependencies already exists"></p><p>We start testing pretty quickly, since we don&#39;t have to install or build anything.</p><h2><span id="using-the-cypress-action">Using The Cypress Action</span></h2><p>Many years ago I wrote <a href="https://github.com/cypress-io/github-action">cypress-io&#x2F;github-action</a>, and I am happy to report that it works well with the Docker image with baked DEV dependencies. Here is another workflow job that simply uses the action after sym linking:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">test-action:</span></span><br><span class="line">  <span class="comment"># this job finishes after the Docker image is built (or exists already)</span></span><br><span class="line">  <span class="comment"># and verifies the Cypress GitHub action works</span></span><br><span class="line">  <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">  <span class="attr">needs:</span> [<span class="string">package-hash</span>, <span class="string">wait-for-build</span>]</span><br><span class="line">  <span class="attr">container:</span> <span class="string">bahmutov/cy:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">steps:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">      <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">      <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># the important step: symlink the node modules</span></span><br><span class="line">    <span class="comment"># from the Docker image into the working folder</span></span><br><span class="line">    <span class="comment"># so we can skip the installation step</span></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Symlink</span> <span class="string">node</span> <span class="string">modules</span></span><br><span class="line">      <span class="attr">run:</span> <span class="string">ln</span> <span class="string">-s</span> <span class="string">/e2e/node_modules</span> <span class="string">./node_modules</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># confirm the Cypress action works</span></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Cypress</span> <span class="string">action</span></span><br><span class="line">      <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">      <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line">      <span class="attr">with:</span></span><br><span class="line">        <span class="attr">install:</span> <span class="literal">false</span></span><br></pre></td></tr></table></figure><p><img src="../images/cypress-dependencies-through-docker-image/action.png" alt="The workflow with both custom and Cypress action test jobs"></p><p>Finally, the GitHub Actions summary tells us if the Docker image exists or not based on the checksum.</p><h2><span id="github-container-registry">GitHub Container Registry</span></h2><p>If you are using <a href="https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry">GH Container registry</a>, you can use the same Docker image approach, here is the example workflow from <a href="https://github.com/bahmutov/cypress-tests-image">bahmutov&#x2F;cypress-tests-image</a> repo.</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci-ghcr.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># this workflow uses Docker images via GitHub Container Registry (GHCR)</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">CI</span> <span class="string">Using</span> <span class="string">GHCR</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">env:</span></span><br><span class="line">  <span class="attr">REGISTRY:</span> <span class="string">ghcr.io</span></span><br><span class="line">  <span class="attr">IMAGE_NAME:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.repository</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="comment"># computes the hash of package.json and and stores it in the output</span></span><br><span class="line">  <span class="comment"># also checks if the Docker image with this tag already exists</span></span><br><span class="line">  <span class="comment"># outputs:</span></span><br><span class="line">  <span class="comment">#   hash: the package.json hash</span></span><br><span class="line">  <span class="comment">#   tag: whether the Docker image with this tag already exists, &quot;found&quot; or &quot;not found&quot;</span></span><br><span class="line">  <span class="attr">package-hash:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">outputs:</span></span><br><span class="line">      <span class="attr">hash:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.tag-exists.outputs.tag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="comment"># only needed to get package.json and Dockerfile to compute the hash</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">sparse-checkout:</span> <span class="string">|</span></span><br><span class="line"><span class="string">            package.json</span></span><br><span class="line"><span class="string">            Dockerfile</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Package.json</span> <span class="string">+</span> <span class="string">Dockerfile</span> <span class="string">checksum</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">hash</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">echo</span> <span class="string">&quot;checksum=$<span class="template-variable">&#123;&#123; hashFiles(&#x27;package.json&#x27;, &#x27;Dockerfile&#x27;) &#125;&#125;</span>&quot;</span> <span class="string">&gt;&gt;</span> <span class="string">$GITHUB_OUTPUT</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># https://github.com/tyriis/docker-image-tag-exists</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Check</span> <span class="string">if</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">tag</span> <span class="string">exists</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">tag-exists</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">tyriis/docker-image-tag-exists@v2.1.0</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">repository:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># The container image tag</span></span><br><span class="line">          <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Report</span> <span class="string">the</span> <span class="string">check</span> <span class="string">results</span></span><br><span class="line">        <span class="comment"># print the tag result into Github Actions summary</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;## Docker image check&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Registry: $&#123;&#123; env.REGISTRY &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Image: $&#123;&#123; env.REGISTRY &#125;&#125;/$&#123;&#123; env.IMAGE_NAME &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Package.json + Dockerfile hash: $&#123;&#123; steps.hash.outputs.checksum &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Docker image $&#123;&#123; env.REGISTRY &#125;&#125;/$&#123;&#123; env.IMAGE_NAME &#125;&#125;:$&#123;&#123; steps.hash.outputs.checksum &#125;&#125; **$&#123;&#123; steps.tag-exists.outputs.tag &#125;&#125;**&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">  <span class="attr">build-docker-image:</span></span><br><span class="line">    <span class="comment"># builds the Docker image and pushes it to the registry</span></span><br><span class="line">    <span class="comment"># but only if it does not exist yet</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">permissions:</span></span><br><span class="line">      <span class="comment"># don&#x27;t forget to allow workflows to write to GHCR</span></span><br><span class="line">      <span class="comment"># https://github.com/&lt;org&gt;/&lt;repo&gt;/settings/actions</span></span><br><span class="line">      <span class="attr">packages:</span> <span class="string">write</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Log</span> <span class="string">in</span> <span class="string">to</span> <span class="string">GitHub</span> <span class="string">Container</span> <span class="string">Registry</span></span><br><span class="line">        <span class="comment"># https://github.com/docker/login-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">username:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.actor</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># we could also use the action</span></span><br><span class="line">      <span class="comment"># https://github.com/docker/build-push-action</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Build</span> <span class="string">docker</span> <span class="string">image</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">build</span> <span class="string">-t</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span> <span class="string">.</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Push</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">to</span> <span class="string">the</span> <span class="string">correct</span> <span class="string">registry</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">push</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">wait-for-build:</span></span><br><span class="line">    <span class="comment"># a trick to allow other jobs to run, even if the &quot;build&quot; job is skipped</span></span><br><span class="line">    <span class="comment"># runs in parallel with the &quot;build&quot; job and keeps checking if it is finished</span></span><br><span class="line">    <span class="comment"># or is skipped</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Wait</span> <span class="string">for</span> <span class="string">the</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">build</span> <span class="string">/</span> <span class="string">skip</span></span><br><span class="line">        <span class="comment"># https://github.com/lewagon/wait-on-check-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">lewagon/wait-on-check-action@v1.4.1</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">ref:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.ref</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">check-name:</span> <span class="string">build-docker-image</span></span><br><span class="line">          <span class="attr">repo-token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># seconds between checks</span></span><br><span class="line">          <span class="attr">wait-interval:</span> <span class="number">10</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">test:</span></span><br><span class="line">    <span class="comment"># this job finishes after the Docker image is built (or exists already)</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> [<span class="string">package-hash</span>, <span class="string">wait-for-build</span>]</span><br><span class="line">    <span class="comment"># seems we cannot use the env variables here</span></span><br><span class="line">    <span class="attr">container:</span> <span class="string">ghcr.io/$&#123;&#123;</span> <span class="string">github.repository</span> <span class="string">&#125;&#125;:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># THE IMPORTANT STEP: symlink the node modules</span></span><br><span class="line">      <span class="comment"># from the Docker image into the working folder</span></span><br><span class="line">      <span class="comment"># so we can skip the installation step</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Symlink</span> <span class="string">node</span> <span class="string">modules</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">ln</span> <span class="string">-s</span> <span class="string">/e2e/node_modules</span> <span class="string">./node_modules</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">Cypress</span> <span class="string">version</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">--version</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">Cypress</span> <span class="string">tests</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">run</span></span><br></pre></td></tr></table></figure><p>I tried to make the workflow slightly more generic by moving the registry and the image into the <code>env</code> variables:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">env:</span></span><br><span class="line">  <span class="attr">REGISTRY:</span> <span class="string">ghcr.io</span></span><br><span class="line">  <span class="attr">IMAGE_NAME:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.repository</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p><img src="../images/cypress-dependencies-through-docker-image/ghcr.png" alt="Docker image via GitHub container registry"></p><p>Nice.</p><h2><span id="google-artifact-registry">Google Artifact Registry</span></h2><p>Let&#39;s say you want to use private Docker images stored on <a href="https://docs.cloud.google.com/artifact-registry/docs/overview">Google Artifact Registry</a>. You need to create it on GCP, then create a Service Account to access it from CI (this is simpler for me than using Workload identity), see <a href="https://docs.cloud.google.com/artifact-registry/docs/access-control">the docs</a>.</p><p>So here is my Artifact Registry called <code>gleb-google-artifact-registry-test</code> running inside <code>us-east4-docker.pkg.dev</code> configured to run under the project <code>helloworld-330918</code></p><p><img src="../images/cypress-dependencies-through-docker-image/artifact-registry.png" alt="Artifact Registry"></p><p>We will write and read Docker images using name <code>cypress-tests-image</code>. I will use a single Service Account for this with these permissions</p><p><img src="../images/cypress-dependencies-through-docker-image/service-account.png" alt="Service account configuration"></p><p>For this account, grab the JSON key file and set it as GitHub Actions environment secret <code>GCR_KEY</code>. Now let&#39;s build and use images with dependencies. Here is the workflow file from <a href="https://github.com/bahmutov/cypress-tests-image/blob/main/.github/workflows/ci-gcp.yml">bahmutov&#x2F;cypress-tests-image</a>.</p><figure class="highlight yml"><figcaption><span>.github/workflows/ci-gcp.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># this workflow uses Docker images via Google Artifacts Registry (GCR)</span></span><br><span class="line"><span class="comment"># the registry is private, so we will use a Service Account to log in</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">CI</span> <span class="string">Using</span> <span class="string">Google</span> <span class="string">Artifacts</span> <span class="string">Registry</span></span><br><span class="line"><span class="attr">on:</span> <span class="string">push</span></span><br><span class="line"></span><br><span class="line"><span class="attr">env:</span></span><br><span class="line">  <span class="comment"># gcr.io / Artifacts Registry</span></span><br><span class="line">  <span class="attr">REGISTRY:</span> <span class="string">us-east4-docker.pkg.dev</span></span><br><span class="line">  <span class="attr">GCP_PROJECT:</span> <span class="string">helloworld-330918</span></span><br><span class="line">  <span class="comment"># Artifacts Registry repository name</span></span><br><span class="line">  <span class="attr">REPOSITORY:</span> <span class="string">gleb-google-artifact-registry-test</span></span><br><span class="line">  <span class="attr">IMAGE_NAME:</span> <span class="string">cypress-tests-image</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="comment"># computes the hash of package.json and and stores it in the output</span></span><br><span class="line">  <span class="comment"># also checks if the Docker image with this tag already exists</span></span><br><span class="line">  <span class="comment"># outputs:</span></span><br><span class="line">  <span class="comment">#   hash: the package.json hash</span></span><br><span class="line">  <span class="comment">#   tag: whether the Docker image with this tag already exists, &quot;found&quot; or &quot;not found&quot;</span></span><br><span class="line">  <span class="attr">package-hash:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">outputs:</span></span><br><span class="line">      <span class="attr">hash:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.tag-exists.outputs.tag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="comment"># only needed to get package.json and Dockerfile to compute the hash</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">sparse-checkout:</span> <span class="string">|</span></span><br><span class="line"><span class="string">            package.json</span></span><br><span class="line"><span class="string">            Dockerfile</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Package.json</span> <span class="string">+</span> <span class="string">Dockerfile</span> <span class="string">checksum</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">hash</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">echo</span> <span class="string">&quot;checksum=$<span class="template-variable">&#123;&#123; hashFiles(&#x27;package.json&#x27;, &#x27;Dockerfile&#x27;) &#125;&#125;</span>&quot;</span> <span class="string">&gt;&gt;</span> <span class="string">$GITHUB_OUTPUT</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Log</span> <span class="string">in</span> <span class="string">to</span> <span class="string">Google</span> <span class="string">Artifacts</span> <span class="string">Registry</span></span><br><span class="line">        <span class="comment"># https://github.com/docker/login-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">username:</span> <span class="string">_json_key</span></span><br><span class="line">          <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GCR_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># https://github.com/tyriis/docker-image-tag-exists</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Check</span> <span class="string">if</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">tag</span> <span class="string">exists</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">tag-exists</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">tyriis/docker-image-tag-exists@v2.1.0</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">repository:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.GCP_PROJECT</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.REPOSITORY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># The container image tag</span></span><br><span class="line">          <span class="attr">tag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.hash.outputs.checksum</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Report</span> <span class="string">the</span> <span class="string">check</span> <span class="string">results</span></span><br><span class="line">        <span class="comment"># print the tag result into Github Actions summary</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;## Docker image check&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Registry: $&#123;&#123; env.REGISTRY &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Image: $&#123;&#123; env.REGISTRY &#125;&#125;/$&#123;&#123; env.GCP_PROJECT &#125;&#125;/$&#123;&#123; env.REPOSITORY &#125;&#125;/$&#123;&#123; env.IMAGE_NAME &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Package.json + Dockerfile hash: $&#123;&#123; steps.hash.outputs.checksum &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string">          echo &quot;Docker image $&#123;&#123; env.REGISTRY &#125;&#125;/$&#123;&#123; env.IMAGE_NAME &#125;&#125;:$&#123;&#123; steps.hash.outputs.checksum &#125;&#125; **$&#123;&#123; steps.tag-exists.outputs.tag &#125;&#125;**&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">  <span class="attr">build-docker-image:</span></span><br><span class="line">    <span class="comment"># builds the Docker image and pushes it to the registry</span></span><br><span class="line">    <span class="comment"># but only if it does not exist yet</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">permissions:</span></span><br><span class="line">      <span class="comment"># don&#x27;t forget to allow workflows to write to GHCR</span></span><br><span class="line">      <span class="comment"># https://github.com/&lt;org&gt;/&lt;repo&gt;/settings/actions</span></span><br><span class="line">      <span class="attr">packages:</span> <span class="string">write</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.tag</span> <span class="string">==</span> <span class="string">&#x27;not found&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Log</span> <span class="string">in</span> <span class="string">to</span> <span class="string">Google</span> <span class="string">Artifacts</span> <span class="string">Registry</span></span><br><span class="line">        <span class="comment"># https://github.com/docker/login-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">registry:</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">username:</span> <span class="string">_json_key</span></span><br><span class="line">          <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GCR_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># we could also use the action</span></span><br><span class="line">      <span class="comment"># https://github.com/docker/build-push-action</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Build</span> <span class="string">docker</span> <span class="string">image</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">build</span> <span class="string">-t</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.GCP_PROJECT</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.REPOSITORY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span> <span class="string">.</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Push</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">to</span> <span class="string">the</span> <span class="string">correct</span> <span class="string">registry</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">docker</span> <span class="string">push</span> <span class="string">$&#123;&#123;</span> <span class="string">env.REGISTRY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.GCP_PROJECT</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.REPOSITORY</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">env.IMAGE_NAME</span> <span class="string">&#125;&#125;:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">wait-for-build:</span></span><br><span class="line">    <span class="comment"># a trick to allow other jobs to run, even if the &quot;build&quot; job is skipped</span></span><br><span class="line">    <span class="comment"># runs in parallel with the &quot;build&quot; job and keeps checking if it is finished</span></span><br><span class="line">    <span class="comment"># or is skipped</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">package-hash</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Wait</span> <span class="string">for</span> <span class="string">the</span> <span class="string">Docker</span> <span class="string">image</span> <span class="string">build</span> <span class="string">/</span> <span class="string">skip</span></span><br><span class="line">        <span class="comment"># https://github.com/lewagon/wait-on-check-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">lewagon/wait-on-check-action@v1.4.1</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">ref:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.ref</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">check-name:</span> <span class="string">build-docker-image</span></span><br><span class="line">          <span class="attr">repo-token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># seconds between checks</span></span><br><span class="line">          <span class="attr">wait-interval:</span> <span class="number">10</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">test:</span></span><br><span class="line">    <span class="comment"># this job finishes after the Docker image is built (or exists already)</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">needs:</span> [<span class="string">package-hash</span>, <span class="string">wait-for-build</span>]</span><br><span class="line">    <span class="comment"># seems we cannot use the env variables here</span></span><br><span class="line">    <span class="attr">container:</span></span><br><span class="line">      <span class="attr">image:</span> <span class="string">us-east4-docker.pkg.dev/helloworld-330918/gleb-google-artifact-registry-test/cypress-tests-image:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">credentials:</span></span><br><span class="line">        <span class="attr">username:</span> <span class="string">_json_key</span></span><br><span class="line">        <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GCR_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎️</span></span><br><span class="line">        <span class="comment"># https://github.com/actions/checkout</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># THE IMPORTANT STEP: symlink the node modules</span></span><br><span class="line">      <span class="comment"># from the Docker image into the working folder</span></span><br><span class="line">      <span class="comment"># so we can skip the installation step</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Symlink</span> <span class="string">node</span> <span class="string">modules</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">ln</span> <span class="string">-s</span> <span class="string">/e2e/node_modules</span> <span class="string">./node_modules</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">Cypress</span> <span class="string">version</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">--version</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">Cypress</span> <span class="string">tests</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">cypress</span> <span class="string">run</span></span><br></pre></td></tr></table></figure><p>Weird GitHub Actions limitation: one cannot use <code>env</code> keys inside the <code>image: ...</code> string, thus we have to duplicate the full Docker string</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">env:</span></span><br><span class="line">  <span class="comment"># gcr.io / Artifacts Registry</span></span><br><span class="line">  <span class="attr">REGISTRY:</span> <span class="string">us-east4-docker.pkg.dev</span></span><br><span class="line">  <span class="attr">GCP_PROJECT:</span> <span class="string">helloworld-330918</span></span><br><span class="line">  <span class="comment"># Artifacts Registry repository name</span></span><br><span class="line">  <span class="attr">REPOSITORY:</span> <span class="string">gleb-google-artifact-registry-test</span></span><br><span class="line">  <span class="attr">IMAGE_NAME:</span> <span class="string">cypress-tests-image</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># later</span></span><br><span class="line"><span class="attr">container:</span></span><br><span class="line">  <span class="attr">image:</span> <span class="string">us-east4-docker.pkg.dev/helloworld-330918/gleb-google-artifact-registry-test/cypress-tests-image:$&#123;&#123;</span> <span class="string">needs.package-hash.outputs.hash</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">credentials:</span></span><br><span class="line">    <span class="attr">username:</span> <span class="string">_json_key</span></span><br><span class="line">    <span class="attr">password:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GCR_KEY</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>The finished workflow works great</p><p><img src="../images/cypress-dependencies-through-docker-image/gcr-workflow.png" alt="Artifact Registry in action"></p><p>Just keep the <code>GCR_KEY</code> secret!</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;If you are testing a website, the DEV dependencies do not change very often. You might bump Cypress version once in a while, add or upgra
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="github" scheme="https://glebbahmutov.com/blog/tags/github/"/>
    
      <category term="docker" scheme="https://glebbahmutov.com/blog/tags/docker/"/>
    
  </entry>
  
  <entry>
    <title>Reclaiming My Attention</title>
    <link href="https://glebbahmutov.com/blog/reclaiming-my-attention/"/>
    <id>https://glebbahmutov.com/blog/reclaiming-my-attention/</id>
    <published>2026-01-07T05:00:00.000Z</published>
    <updated>2026-01-07T23:16:20.701Z</updated>
    
    <content type="html"><![CDATA[<p>This year I am reclaiming my attention span away from my iPhone. Lately, I have noticed that while reading or working, I reach for my phone to check email &#x2F; BlueSky every couple of minutes. I tried to force myself to focus on work &#x2F; book by using a timer; do not touch the phone until the timer is up. But I think I need a more drastic measure to restore my attention and focus.</p><p>So I got myself a &quot;plain&quot; music player so I can read &#x2F; think &#x2F; walk in peace and leave my phone at home. <a href="https://www.fiio.com/echomini">ECHO Mini</a> plays everything, is tiny, has 2 audio jacks, has bluetooth, and even shows a tiny cassette tape on its front display!</p><p><img src="../images/reclaiming-my-attention/watch-and-player.jpg" alt="ECHO Mini music player plus Bose headphones and a regular watch"></p><p>So now I leave my phone at home and bring a music player (which can fit lots of MP3&#x2F;OGG&#x2F;M4A&#x2F;FLAC files), some headphones, and a regular watch to know the time. Now I can think, read, and work in peace.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;This year I am reclaiming my attention span away from my iPhone. Lately, I have noticed that while reading or working, I reach for my pho
      
    
    </summary>
    
      <category term="people" scheme="https://glebbahmutov.com/blog/categories/people/"/>
    
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>NPM Install And Copilot Instructions</title>
    <link href="https://glebbahmutov.com/blog/npm-install-and-copilot-instructions/"/>
    <id>https://glebbahmutov.com/blog/npm-install-and-copilot-instructions/</id>
    <published>2025-12-30T05:00:00.000Z</published>
    <updated>2025-12-30T18:53:51.132Z</updated>
    
    <content type="html"><![CDATA[<p>Here is something I have noticed about my projects and using Copilot lately. When I add a new NPM dependency, I also update the Copilot instructions file to include examples of how I want to use that dependency in my tests.</p><p>For example, let&#39;s say I need to test if item prices on the inventory page are sorted.</p><p><img src="../images/npm-install-and-copilot-instructions/01-prices.png" alt="Inventory prices sorted low to high"></p><p>We can use <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> queries to create a retry-able chain of commands.</p><figure class="highlight js"><figcaption><span>cypress/e2e/sorted-prices.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">LoginPage</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@support/pages/login.page&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Inventory page&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// visit the login page before each test</span></span><br><span class="line">  <span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> user = <span class="title class_">Cypress</span>.<span class="title function_">env</span>(<span class="string">&#x27;users&#x27;</span>).<span class="property">standard</span></span><br><span class="line">    <span class="title class_">LoginPage</span>.<span class="title function_">login</span>(user.<span class="property">username</span>, user.<span class="property">password</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;sorts items by price, low to high&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/inventory&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-test=&quot;product_sort_container&quot;]&#x27;</span>).<span class="title function_">select</span>(<span class="string">&#x27;Price (low to high)&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.inventory_item_price&#x27;</span>)</span><br><span class="line">      .<span class="title function_">map</span>(<span class="string">&#x27;innerText&#x27;</span>)</span><br><span class="line">      <span class="comment">// slice the leading $</span></span><br><span class="line">      .<span class="title function_">mapInvoke</span>(<span class="string">&#x27;slice&#x27;</span>, <span class="number">1</span>)</span><br><span class="line">      .<span class="title function_">map</span>(<span class="built_in">parseFloat</span>)</span><br><span class="line">      .<span class="title function_">print</span>(<span class="string">&#x27;prices %o&#x27;</span>)</span><br><span class="line">      <span class="comment">// confirm the numbers are sorted in ascending order</span></span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><blockquote class="pullquote"><p>🎓 This example comes from my online courses <a href="https://cypress.tips/courses/swag-store">Testing The Swag Store</a> and <a href="https://cypress.tips/courses/cy-copilot">Write Cypress Tests Using GitHub Copilot</a>.</p></blockquote><p>How do we write the last assertion following the <code>// confirm the numbers are sorted in ascending order</code> comment? We can ask Copilot or wait for it to suggest the code completion. Here is one possible suggestion:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">.<span class="title function_">then</span>(<span class="function">(<span class="params">prices</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> sortedPrices = [...prices].<span class="title function_">sort</span>(<span class="function">(<span class="params">a, b</span>) =&gt;</span> a - b)</span><br><span class="line">  <span class="title function_">expect</span>(prices).<span class="property">to</span>.<span class="property">deep</span>.<span class="title function_">equal</span>(sortedPrices)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/npm-install-and-copilot-instructions/02-suggestion.png" alt="Copilot suggestion to check sorted prices"></p><p>The test runs and shows an anonymous <code>deeply equal</code> assertion inside the <code>.then(callback)</code> function:</p><p><img src="../images/npm-install-and-copilot-instructions/03-plain-code.png" alt="Prices are sorted"></p><p>Nothing wrong with this suggestion (except it is not retry-able), but I prefer using <a href="https://www.chaijs.com/plugins/chai-sorted/">chai-sorted</a> plugin instead. The assertions are easier to read and much shorter. Let&#39;s install the plugin:</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ npm i -D chai-sorted</span><br></pre></td></tr></table></figure><p>Then we update the <code>cypress/support/e2e.js</code> file to include the plugin:</p><figure class="highlight js"><figcaption><span>cypress/support/e2e.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// https://www.chaijs.com/plugins/chai-sorted/</span></span><br><span class="line">chai.<span class="title function_">use</span>(<span class="built_in">require</span>(<span class="string">&#x27;chai-sorted&#x27;</span>))</span><br></pre></td></tr></table></figure><p>Now we need to tell Copilot to use the newly installed plugin. We can update the Copilot instruction file</p><figure class="highlight md"><figcaption><span>.github/copilot-instructions.md</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">This is a project that uses Cypress for end-to-end testing, and follows best practices.</span><br><span class="line"></span><br><span class="line">To check if the numbers are sorted, always use the chai-sorted plugin for Cypress, for example:</span><br><span class="line"></span><br><span class="line"><span class="code">    // sort the numbers in ascending order</span></span><br><span class="line"><span class="code">    cy.get(&#x27;.price&#x27;).map(&#x27;innerText&#x27;).map(parseFloat).should(&#x27;be.ascending&#x27;)</span></span><br><span class="line"><span class="code">    // sort the numbers in descending order</span></span><br><span class="line"><span class="code">    cy.get(&#x27;.price&#x27;).map(&#x27;innerText&#x27;).map(parseFloat).should(&#x27;be.descending&#x27;)</span></span><br></pre></td></tr></table></figure><p>Now let&#39;s return the test and see what Copilot suggests for the last assertion:</p><p><img src="../images/npm-install-and-copilot-instructions/04-chai-sorted-suggestion.png" alt="Copilot suggestion uses chai-sorted"></p><p>Much shorter and easier to read, and it even includes helpful text, unlike <code>.then(callback)</code> we had before! This is exactly what we wanted. Here is the passing test</p><p><img src="../images/npm-install-and-copilot-instructions/05-passing-test.png" alt="Passing test that uses chai-sorted assertion"></p><p>Perfect.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Here is something I have noticed about my projects and using Copilot lately. When I add a new NPM dependency, I also update the Copilot i
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="copilot" scheme="https://glebbahmutov.com/blog/tags/copilot/"/>
    
  </entry>
  
  <entry>
    <title>Testing Loading Skeletons</title>
    <link href="https://glebbahmutov.com/blog/testing-loading-skeletons/"/>
    <id>https://glebbahmutov.com/blog/testing-loading-skeletons/</id>
    <published>2025-12-26T05:00:00.000Z</published>
    <updated>2025-12-26T16:57:19.177Z</updated>
    
    <content type="html"><![CDATA[<p>Loading skeletons are displayed while the real data is loading. For example, the login passwords are displayed after 1 second in the GIF below, and the loading skeleton is displayed first.</p><p><img src="../images/testing-loading-skeletons/skeleton.gif" alt="Loading skeleton"></p><p>The skeleton itself is simple, just a DIV with some gradient CSS.</p><figure class="highlight jsx"><figcaption><span>src/pages/Login.jsx</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (isLoading) &#123;</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login_logo&quot;</span> /&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login_wrapper&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login_wrapper-inner&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">id</span>=<span class="string">&quot;login_button_container&quot;</span> <span class="attr">className</span>=<span class="string">&quot;form_column&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login-box&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-heading&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-text&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-text&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-heading&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-text&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;bot_column&quot;</span> /&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login_credentials_wrap&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;login_credentials_wrap-inner&quot;</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-heading&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-text&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">&quot;skeleton skeleton-text&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// else return the real component</span></span><br></pre></td></tr></table></figure><figure class="highlight css"><figcaption><span>src/pages/Login.css</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="selector-class">.skeleton</span> &#123;</span><br><span class="line">  <span class="attribute">background</span>: <span class="built_in">linear-gradient</span>(<span class="number">90deg</span>, <span class="number">#f0f0f0</span> <span class="number">25%</span>, <span class="number">#e0e0e0</span> <span class="number">50%</span>, <span class="number">#f0f0f0</span> <span class="number">75%</span>);</span><br><span class="line">  <span class="attribute">background-size</span>: <span class="number">200%</span> <span class="number">100%</span>;</span><br><span class="line">  <span class="attribute">animation</span>: loading <span class="number">1.5s</span> ease-in-out infinite;</span><br><span class="line">  <span class="attribute">border-radius</span>: <span class="number">4px</span>;</span><br><span class="line">  <span class="attribute">margin-bottom</span>: <span class="number">15px</span>;</span><br><span class="line">&#125;</span><br><span class="line"><span class="selector-class">.skeleton-heading</span> &#123;</span><br><span class="line">  <span class="attribute">height</span>: <span class="number">24px</span>;</span><br><span class="line">  <span class="attribute">width</span>: <span class="number">60%</span>;</span><br><span class="line">&#125;</span><br><span class="line"><span class="selector-class">.skeleton-text</span> &#123;</span><br><span class="line">  <span class="attribute">height</span>: <span class="number">16px</span>;</span><br><span class="line">  <span class="attribute">width</span>: <span class="number">100%</span>;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">@keyframes</span> loading &#123;</span><br><span class="line">  <span class="number">0%</span> &#123;</span><br><span class="line">    <span class="attribute">background-position</span>: <span class="number">200%</span> <span class="number">0</span>;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="number">100%</span> &#123;</span><br><span class="line">    <span class="attribute">background-position</span>: -<span class="number">200%</span> <span class="number">0</span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="test-the-loading-skeleton">Test the loading skeleton</span></h2><p>Let&#39;s confirm the loading skeleton is shown initially and then replaced by the real component. If the loading skeleton is always displayed, we can write a test similar to this one:</p><figure class="highlight js"><figcaption><span>cypress/e2e/login/skeleton.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">LoginPage</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@support/pages/login.page&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Login form skeleton&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// visit the login page before each test</span></span><br><span class="line">  <span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows the loading skeleton first&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#login_button_container&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.skeleton&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>).<span class="title function_">and</span>(<span class="string">&#x27;have.length.greaterThan&#x27;</span>, <span class="number">2</span>)</span><br><span class="line">    <span class="comment">// skeleton should go away</span></span><br><span class="line">    <span class="comment">// and the login form is immediately visible (within 100ms)</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.skeleton&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="title class_">LoginPage</span>.<span class="property">selectors</span>.<span class="property">username</span>, &#123; <span class="attr">timeout</span>: <span class="number">100</span> &#125;).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/testing-loading-skeletons/skeleton-test.gif" alt="Testing the skeleton and its disappearance"></p><p><strong>Tip:</strong> if the skeleton does NOT always appear (for example, if the data is cached and the skeleton is skipped), we can clear the data and&#x2F;or slow down the network request to make the skeleton always appear.</p><h2><span id="test-the-skeleton-positioning">Test the skeleton positioning</span></h2><p>One of the disturbing aspects of the loading skeleton is mismatch between its positions and the loaded text. For example, take a look at this loop - can you see the &quot;jump&quot; between the skeleton heading DIV and the &quot;Accepted usernames are:&quot; H4?</p><p><img src="../images/testing-loading-skeletons/skeleton-moves.gif" alt="The heading jumps when the skeleton goes away"></p><p>We want to catch this skeleton position mismatch. Let&#39;s compare the &quot;top&quot; property of the skeleton DIV and the rendered H4 elements - they should be close to each other in order to avoid disorienting the user. We could write a test like this:</p><figure class="highlight ts"><figcaption><span>cypress/e2e/login/skeleton-position.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Login form skeleton&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> skeletonHeading = <span class="string">&#x27;.login_credentials_wrap .skeleton-heading&#x27;</span></span><br><span class="line">  <span class="keyword">const</span> loginCredentials = <span class="string">&#x27;.login_credentials_wrap .login_credentials h4&#x27;</span></span><br><span class="line">  <span class="keyword">const</span> tolerance = <span class="number">20</span> <span class="comment">// pixels</span></span><br><span class="line"></span><br><span class="line">  <span class="comment">// visit the login page before each test</span></span><br><span class="line">  <span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;does not move from the top&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// confirm the skeleton heading does NOT change</span></span><br><span class="line">    <span class="comment">// it &quot;top&quot; position on the page too much (within tolerance)</span></span><br><span class="line">    cy.<span class="title function_">get</span>(skeletonHeading)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="keyword">const</span> rect = $el[<span class="number">0</span>].<span class="title function_">getBoundingClientRect</span>()</span><br><span class="line">        <span class="keyword">return</span> rect.<span class="property">top</span></span><br><span class="line">      &#125;)</span><br><span class="line">      <span class="comment">// round to pixels for nicer comparison</span></span><br><span class="line">      .<span class="title function_">then</span>(<span class="title class_">Math</span>.<span class="property">round</span>)</span><br><span class="line">      .<span class="title function_">as</span>(<span class="string">&#x27;initialTop&#x27;</span>, &#123; <span class="attr">type</span>: <span class="string">&#x27;static&#x27;</span> &#125;)</span><br><span class="line">    cy.<span class="title function_">get</span>(skeletonHeading).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;@initialTop&#x27;</span>).<span class="title function_">then</span>(<span class="function">(<span class="params">initialTop</span>) =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(loginCredentials)</span><br><span class="line">        .<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="function">(<span class="params">$el</span>) =&gt;</span> &#123;</span><br><span class="line">          <span class="keyword">const</span> rect = $el[<span class="number">0</span>].<span class="title function_">getBoundingClientRect</span>()</span><br><span class="line">          <span class="keyword">return</span> rect.<span class="property">top</span></span><br><span class="line">        &#125;)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="title class_">Math</span>.<span class="property">round</span>)</span><br><span class="line">        .<span class="title function_">should</span>(<span class="string">&#x27;be.closeTo&#x27;</span>, initialTop, tolerance)</span><br><span class="line">    &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The test catches the heading skeleton &quot;moving&quot; 100 pixels when the real heading H4 element is shown.</p><p><img src="../images/testing-loading-skeletons/top.png" alt="The test fails if the skeleton is too far from its real element"></p><p>Because I love writing concise and elegant code, I will use my <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> plugin to rewrite the above test a little bit to make it shorter and easier to read.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;does not move from the top (cypress-map)&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// confirm the skeleton heading does NOT change</span></span><br><span class="line">  <span class="comment">// it &quot;top&quot; position on the page too much (within tolerance)</span></span><br><span class="line">  cy.<span class="title function_">get</span>(skeletonHeading)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invokeFirst</span>(<span class="string">&#x27;getBoundingClientRect&#x27;</span>)</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;top&#x27;</span>)</span><br><span class="line">    <span class="comment">// round to pixels for nicer comparison</span></span><br><span class="line">    .<span class="title function_">then</span>(<span class="title class_">Math</span>.<span class="property">round</span>)</span><br><span class="line">    .<span class="title function_">as</span>(<span class="string">&#x27;initialTop&#x27;</span>, &#123; <span class="attr">type</span>: <span class="string">&#x27;static&#x27;</span> &#125;)</span><br><span class="line">  cy.<span class="title function_">get</span>(skeletonHeading).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;@initialTop&#x27;</span>).<span class="title function_">then</span>(<span class="function">(<span class="params">initialTop</span>) =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(loginCredentials)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">      .<span class="title function_">invokeFirst</span>(<span class="string">&#x27;getBoundingClientRect&#x27;</span>)</span><br><span class="line">      .<span class="title function_">its</span>(<span class="string">&#x27;top&#x27;</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="title class_">Math</span>.<span class="property">round</span>)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;be.closeTo&#x27;</span>, initialTop, tolerance)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Now let&#39;s fix the skeleton &quot;jump&quot;, I found the following problem in the CSS</p><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="selector-class">.skeleton-heading</span> &#123;</span><br><span class="line">  <span class="comment">/* why are we shifting the skeleton heading by 100 pixels?!! */</span></span><br><span class="line">  <span class="attribute">position</span>: relative;</span><br><span class="line">  <span class="attribute">top</span>: <span class="number">100px</span>;</span><br><span class="line">  <span class="attribute">height</span>: <span class="number">24px</span>;</span><br><span class="line">  <span class="attribute">width</span>: <span class="number">60%</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Let&#39;s remove the <code>position: relative</code> and <code>top: 100px</code> from the skeleton&#39;s heading CSS. The test is now green - the skeleton heading is pretty close to where the real H4 appears.</p><p><img src="../images/testing-loading-skeletons/fixed-top.gif" alt="The skeleton is close to the real heading"></p><p>We can similarly check other loading skeleton dimensions: left, bottom, and right. We can also check other skeleton DIV elements, not just the heading.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Loading skeletons are displayed while the real data is loading. For example, the login passwords are displayed after 1 second in the GIF 
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
  </entry>
  
  <entry>
    <title>How To Type Function Mocha Context With Cypress Aliases</title>
    <link href="https://glebbahmutov.com/blog/type-test-context/"/>
    <id>https://glebbahmutov.com/blog/type-test-context/</id>
    <published>2025-12-11T05:00:00.000Z</published>
    <updated>2025-12-11T22:08:57.247Z</updated>
    
    <content type="html"><![CDATA[<p>In Cypress you can save values under <a href="https://on.cypress.io/as">aliases</a> which is pretty handy. You can get the aliased value using the <a href="https://on.cypress.io/get">cy.get</a>, or by using a <code>cy.then(function () &#123; ... &#125;)</code> callback, or (my favorite solution) using the alias in the next hook or test callback via <code>this.[alias]</code> property</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">wrap</span>(<span class="number">42</span>).<span class="title function_">as</span>(<span class="string">&#x27;answer&#x27;</span>)</span><br><span class="line">  <span class="comment">// later in the same code</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;@answer&#x27;</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;equal&#x27;</span>, <span class="number">42</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// alternative 1: cy.then(callback)</span></span><br><span class="line">  cy.<span class="title function_">get</span>(...)</span><br><span class="line">    .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">      <span class="comment">// we can access the aliased value</span></span><br><span class="line">      <span class="title function_">expect</span>(<span class="variable language_">this</span>.<span class="property">answer</span>, <span class="string">&#x27;answer&#x27;</span>).<span class="property">to</span>.<span class="title function_">equal</span>(<span class="number">42</span>)</span><br><span class="line">    &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// alternative 2</span></span><br><span class="line"><span class="comment">// by the time the test runs, the alias property is set</span></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;works&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="title function_">expect</span>(<span class="variable language_">this</span>.<span class="property">answer</span>, <span class="string">&#x27;test answer&#x27;</span>).<span class="property">to</span>.<span class="title function_">equal</span>(<span class="number">42</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Using the <code>function () &#123; ... &#125;</code> rather than <code>() =&gt; &#123; ... &#125;</code> is very important; we need the <code>this</code> reference to point at the Mocha context object where Cypress aliases are stored.</p><p>All is good, except types; our linter &#x2F; code editor does not know that <code>this</code> test context is expected to have a new custom property <code>answer</code>, what do we do?</p><p><img src="../images/type-test-context/any.png" alt="Unknown property &quot;answer&quot; on the test context object"></p><p>If we look at the type definition for the <code>it(title, ...)</code> function, we can find the Mocha types bundled with Cypress</p><p><img src="../images/type-test-context/it-type.png" alt="The type of the &quot;it&quot; test function"></p><p><img src="../images/type-test-context/test-function.png" alt="Open the type definition for the test functions"></p><p>The type for the callback function <code>Func</code> is simply</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Callback function used for tests and hooks.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Func</span> = <span class="function">(<span class="params"><span class="variable language_">this</span>: Context, done: Done</span>) =&gt;</span> <span class="built_in">void</span>;</span><br></pre></td></tr></table></figure><p>The first argument is <code>this: Context</code> and that is what we need to extend with our custom property <code>answer</code>. Unfortunately, it is not exposed, so we cannot simply <a href="/blog/typed-cy-task/" title="merge interfaces">merge interfaces</a> (like we do with custom Cypress commands), so we need to overwrite the <code>type Func</code> signature.</p><figure class="highlight ts"><figcaption><span>cypress/e2e/misc/context-types.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">declare</span> <span class="keyword">namespace</span> <span class="title class_">Mocha</span> &#123;</span><br><span class="line">  <span class="keyword">interface</span> <span class="title class_">MyContext</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * The answer to life, the universe, and everything.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@example</span> console.log(this.answer) // 42</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="attr">answer</span>: <span class="built_in">number</span></span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Callback function used for tests and hooks.</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="keyword">type</span> <span class="title class_">MyFunc</span> = <span class="function">(<span class="params"><span class="variable language_">this</span>: MyContext</span>) =&gt;</span> <span class="built_in">void</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">interface</span> <span class="title class_">TestFunction</span> &#123;</span><br><span class="line">    (<span class="attr">title</span>: <span class="built_in">string</span>, fn?: <span class="title class_">MyFunc</span>): <span class="title class_">Mocha</span>.<span class="property">Test</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Mocha context types&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">wrap</span>(<span class="number">42</span>).<span class="title function_">as</span>(<span class="string">&#x27;answer&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;accesses aliased object via this&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">    <span class="comment">// access the aliased object via &quot;this&quot;</span></span><br><span class="line">    <span class="comment">// and assert its property &quot;answer&quot; is 42</span></span><br><span class="line">    <span class="title function_">expect</span>(<span class="variable language_">this</span>.<span class="property">answer</span>, <span class="string">&#x27;answer&#x27;</span>).<span class="property">to</span>.<span class="title function_">equal</span>(<span class="number">42</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>By putting the <code>Mocha.MyContext</code> interface in the spec file we restrict its effect on the current spec. Now it should work</p><p><img src="../images/type-test-context/works.png" alt="Cypress test callback with typed custom alias property"></p><p>Strong typing for the win.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;In Cypress you can save values under &lt;a href=&quot;https://on.cypress.io/as&quot;&gt;aliases&lt;/a&gt; which is pretty handy. You can get the aliased value 
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="typescript" scheme="https://glebbahmutov.com/blog/tags/typescript/"/>
    
  </entry>
  
  <entry>
    <title>Test Auto-healing Is A Red Flag</title>
    <link href="https://glebbahmutov.com/blog/test-autohealing-is-a-red-flag/"/>
    <id>https://glebbahmutov.com/blog/test-autohealing-is-a-red-flag/</id>
    <published>2025-12-10T05:00:00.000Z</published>
    <updated>2025-12-10T16:45:14.382Z</updated>
    
    <content type="html"><![CDATA[<p>Well, I am happy to report Cypress-the-company is <a href="https://www.cypress.io/blog/ai-self-healing-in-cypress-reliable-tests-with-full-visibility">all in on test auto-healing</a> in its new <a href="https://on.cypress.io/prompt">cy.prompt</a> command (which I recreated locally as <a href="https://github.com/bahmutov/cypress-think">cypress-think</a> plugin). Let&#39;s look at the screenshots Cypress put in the blog post:</p><p><img src="../images/autohealing/auto1.png" alt="The auto-healed test"></p><p>The test shows Cypress auto-healing in practice. The test had a prompt like &quot;Turn on dark mode&quot;, which used to resolve to some button (unclear). Now the button is gone, but the test has been &quot;auto-healed&quot; to find another button. We can see the details of the healing by clicking on the &quot;Turn on dark mode&quot; step in the Command Log:</p><p><img src="../images/autohealing/auto2.png" alt="The new &quot;dark mode&quot; button"></p><blockquote><p>Please do not use any auto-healing in your tests.</p></blockquote><p>It is a huge red flag that your development and testing process is not working as intended. Let&#39;s look at the situation above: the original test found a &quot;button.group&quot; element. Which element was it? Did <em>anyone</em> confirm or review this button to ensure the user would understand its purpose? Did the button have a &quot;title&quot; attribute? An &quot;on&#x2F;off&quot; state to ensure the user understands its role? Did anyone review the initial implementation code and its independent testing code?</p><p>I guarantee that NO ONE will be looking at the AI-generated or AI-healed tests to ensure the actual executed test commands are correct and the test is exercising the app correctly. NO ONE. EVER. EVER (repeat like the Outkast song) How often do you look at successful CI runs? Unless you are debugging a sudden failure, you are NOT looking at the CI logs. Same with tests: if the test is green, it is a black box to be ignored in the deepest corner of the basement.</p><p>Adding auto-healing is like adding a giant wildcard character to <em>every</em> test command, element selector, assertion, etc.</p><ul><li>Not updating the expectations when updating the front-end code is a red flag.</li><li>Not reviewing the test changes is a red flag.</li><li>Not running end-to-end tests locally when updating the code is a red flag.</li><li>Not <em>having</em> explicit tests is a red flag.</li></ul><p>Imagine a login form:</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">input</span> <span class="attr">type</span>=<span class="string">&quot;email&quot;</span> <span class="attr">placeholder</span>=<span class="string">&quot;Enter your email&quot;</span> /&gt;</span></span><br></pre></td></tr></table></figure><p>An AI prompt &quot;Enter the user email&quot; can correctly pick this input field and use <code>cy.get(&#39;input[type=&quot;email&quot;]&#39;).type(...)</code> command. Then something changes and the placeholder is showing a pretty interesting attitude:</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">input</span> <span class="attr">type</span>=<span class="string">&quot;text&quot;</span> <span class="attr">placeholder</span>=<span class="string">&quot;Dummy email&quot;</span> /&gt;</span></span><br></pre></td></tr></table></figure><p>Well, the auto-healing can certainly &quot;guess&quot; that this is the same email input field, even if its type has changed from &quot;email&quot; to &quot;text&quot;. But the changed placeholder is probably NOT something that we should show on the login page! Yet, no one will review the actual test commands until an angry user complains on a Reddit form &quot;Acme.co called me a dummy!&quot;.</p><p>Auto-healing tests is like a medical doctor telling its patients to use ChatGPT to self-medicate. I&#39;m sure it will be fine &#x2F;s</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Well, I am happy to report Cypress-the-company is &lt;a href=&quot;https://www.cypress.io/blog/ai-self-healing-in-cypress-reliable-tests-with-ful
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Branded Types</title>
    <link href="https://glebbahmutov.com/blog/branded-types/"/>
    <id>https://glebbahmutov.com/blog/branded-types/</id>
    <published>2025-12-05T05:00:00.000Z</published>
    <updated>2026-03-21T02:22:29.072Z</updated>
    
    <content type="html"><![CDATA[<p>Let&#39;s say you need to specify a timeout or wait in your end-to-end Cypress tests. You would use milliseconds</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// try finding the &quot;selector&quot; elements for up to 1 second</span></span><br><span class="line">cy.<span class="title function_">get</span>(<span class="string">&#x27;selector&#x27;</span>, &#123; <span class="attr">timeout</span>: <span class="number">1_000</span> &#125;)</span><br><span class="line">  <span class="comment">// then wait for 5 seconds</span></span><br><span class="line">  .<span class="title function_">wait</span>(<span class="number">5_000</span>)</span><br></pre></td></tr></table></figure><p>For clarity, you could write a helper to convert seconds into milliseconds</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">seconds</span> = (<span class="params">n</span>) =&gt; n * <span class="number">1000</span></span><br><span class="line"></span><br><span class="line">cy.<span class="title function_">get</span>(<span class="string">&#x27;selector&#x27;</span>, &#123; <span class="attr">timeout</span>: <span class="title function_">seconds</span>(<span class="number">1</span>) &#125;)</span><br><span class="line">  .<span class="title function_">wait</span>(<span class="title function_">seconds</span>(<span class="number">5</span>))</span><br></pre></td></tr></table></figure><p>What happens if you accidentally forget the <code>seconds(...)</code> call? You still get a spec that passes your linter</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">seconds</span> = (<span class="params">n</span>) =&gt; n * <span class="number">1000</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// oops, GET has a timeout of 1 ms instead of 1000 ms</span></span><br><span class="line">cy.<span class="title function_">get</span>(<span class="string">&#x27;selector&#x27;</span>, &#123; <span class="attr">timeout</span>: <span class="number">1</span> &#125;)</span><br><span class="line">  .<span class="title function_">wait</span>(<span class="title function_">seconds</span>(<span class="number">5</span>))</span><br></pre></td></tr></table></figure><p>TypeScript system would prevent you from accidentally passing a string into <code>cy.wait(...)</code> for example, but not from mixing the units: milliseconds vs seconds, cents vs dollars, dollars vs euros. If you want to distinguish units, you could use &quot;branded types&quot; approach. You could read about branded types <a href="https://www.learningtypescript.com/articles/branded-types">here</a> and <a href="https://tyulenit.com/en/blog/2023-05-15-branding-types/">here</a> and follow this blog post.</p><h2><span id="ms-vs-seconds-branded-types">Ms vs Seconds branded types</span></h2><p>Let&#39;s extend <code>number</code> type with a &quot;brand&quot; property using <code>number &amp; &#123; __brand: T &#125;</code> syntax. We will create one type for milliseconds and another type for seconds. I will put the types into <code>cypress/support/index.d.ts</code> file so it is available in all specs by default.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// cypress/support/index.d.ts</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// time durations branded types</span></span><br><span class="line"><span class="comment">// https://www.learningtypescript.com/articles/branded-types</span></span><br><span class="line"><span class="comment">// https://blog.theodorc.no/posts/branded-types/</span></span><br><span class="line"><span class="comment">// used to represent seconds and milliseconds</span></span><br><span class="line"><span class="comment">// and make it CLEAR which units we are using</span></span><br><span class="line"><span class="comment">// See cypress/e2e/index.ts for conversion functions</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Period</span>&lt;T <span class="keyword">extends</span> <span class="string">&#x27;ms&#x27;</span> | <span class="string">&#x27;seconds&#x27;</span>&gt; = <span class="built_in">number</span> &amp; &#123; <span class="attr">__brand</span>: T &#125;</span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Milliseconds</span> = <span class="title class_">Period</span>&lt;<span class="string">&#x27;ms&#x27;</span>&gt;</span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Seconds</span> = <span class="title class_">Period</span>&lt;<span class="string">&#x27;seconds&#x27;</span>&gt;</span><br></pre></td></tr></table></figure><blockquote class="pullquote"><p>🎓 This blog post is based on the exercise in my &quot;Testing The Swag Store&quot; online course available at <a href="https://cypress.tips/courses">cypress.tips&#x2F;courses</a>.</p></blockquote><p>Because Milliseconds and Seconds are extension of a <code>number</code> type, you can still use them everywhere you can use a number:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;%d duration&#x27;</span>, <span class="number">3</span> <span class="keyword">as</span> <span class="title class_">Seconds</span>)</span><br></pre></td></tr></table></figure><p>TypeScript also understands the type based on an arithmetic operation</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// sum is a &quot;number&quot;</span></span><br><span class="line"><span class="keyword">const</span> sum = <span class="number">2</span> <span class="keyword">as</span> <span class="title class_">Seconds</span> + <span class="number">3</span></span><br></pre></td></tr></table></figure><p>Now let&#39;s create a custom command to &quot;replace&quot; the built-in <code>cy.wait</code> command. Our command will explicitly take <code>Milliseconds</code> argument</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// cypress/support/index.d.ts</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Period</span>&lt;T <span class="keyword">extends</span> <span class="string">&#x27;ms&#x27;</span> | <span class="string">&#x27;seconds&#x27;</span>&gt; = <span class="built_in">number</span> &amp; &#123; <span class="attr">__brand</span>: T &#125;</span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Milliseconds</span> = <span class="title class_">Period</span>&lt;<span class="string">&#x27;ms&#x27;</span>&gt;</span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Seconds</span> = <span class="title class_">Period</span>&lt;<span class="string">&#x27;seconds&#x27;</span>&gt;</span><br><span class="line"></span><br><span class="line"><span class="keyword">declare</span> <span class="keyword">namespace</span> <span class="title class_">Cypress</span> &#123;</span><br><span class="line">  <span class="keyword">interface</span> <span class="title class_">Chainable</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Equivalent to cy.wait(ms) but with explicit branded type for milliseconds.</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="title function_">delay</span>(<span class="attr">period</span>: <span class="title class_">Milliseconds</span>): <span class="title class_">Chainable</span>&lt;<span class="literal">undefined</span>&gt;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The implementation simply delegates to <code>cy.wait</code></p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// cypress/support/commands.ts</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// implementation for &quot;cy.delay(ms)&quot; command</span></span><br><span class="line"><span class="comment">// note that the branded type for Period is just a number</span></span><br><span class="line"><span class="comment">// thus we can pass it to cy.wait(n) command</span></span><br><span class="line"><span class="title class_">Cypress</span>.<span class="property">Commands</span>.<span class="title function_">add</span>(<span class="string">&#x27;delay&#x27;</span>, <span class="function">(<span class="params">period: Milliseconds</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> log = <span class="title class_">Cypress</span>.<span class="title function_">log</span>(&#123;</span><br><span class="line">    <span class="attr">name</span>: <span class="string">&#x27;delay&#x27;</span>,</span><br><span class="line">    <span class="attr">message</span>: <span class="string">`<span class="subst">$&#123;period&#125;</span> millisecond(s)`</span>,</span><br><span class="line">  &#125;)</span><br><span class="line">  <span class="keyword">return</span> cy.<span class="title function_">wait</span>(period, &#123; <span class="attr">log</span>: <span class="literal">false</span> &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2><span id="using-branded-types">Using branded types</span></h2><p>Let&#39;s use <code>cy.delay(ms)</code> from our spec file</p><figure class="highlight ts"><figcaption><span>cypress/e2e/misc/wait-ms.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Waiting with branded types&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;waits using seconds and ms&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// write equivalent to &quot;cy.wait(500)&quot;</span></span><br><span class="line">    <span class="comment">// using explicit branded type &quot;Milliseconds&quot;</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="number">500</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The code works. At runtime, <code>500</code> is a valid argument to pass from <code>cy.delay(500)</code> to <code>cy.wait(500)</code>, but what does our TypeScript say about it? It complains:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">&gt; tsc --noEmit --pretty</span><br><span class="line"></span><br><span class="line">cypress/e2e/misc/wait-ms.cy.ts:7:14 - error TS2345:</span><br><span class="line">Argument of type &#x27;number&#x27; is not assignable to parameter of type &#x27;Milliseconds&#x27;.</span><br><span class="line">  Type &#x27;number&#x27; is not assignable to type &#x27;&#123; __brand: &quot;ms&quot;; &#125;&#x27;.</span><br><span class="line"></span><br><span class="line">7     cy.delay(500)</span><br><span class="line">               ~~~</span><br><span class="line"></span><br><span class="line"></span><br><span class="line">Found 1 error in cypress/e2e/misc/wait-ms.cy.ts:7</span><br></pre></td></tr></table></figure><p>We can&#39;t simply pass a number where <code>Milliseconds</code> is expected, since a number does not have the <code>&amp; &#123; __brand: T &#125;</code> type part. We need to be explicit: we know 500 is a milliseconds value.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// write equivalent to &quot;cy.wait(500)&quot;</span></span><br><span class="line"><span class="comment">// using explicit branded type &quot;Milliseconds&quot;</span></span><br><span class="line">cy.<span class="title function_">delay</span>(<span class="number">500</span> <span class="keyword">as</span> <span class="title class_">Milliseconds</span>)</span><br></pre></td></tr></table></figure><p>There are no more type errors</p><p><img src="../images/branded-types/ms.png" alt="Delay by 500 milliseconds"></p><p>Let&#39;s create a helper to convert seconds into milliseconds</p><figure class="highlight ts"><figcaption><span>cypress/e2e/index.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Returns milliseconds for the given number of seconds.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">seconds</span>(<span class="params">s: Seconds</span>): <span class="title class_">Milliseconds</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="keyword">typeof</span> s !== <span class="string">&#x27;number&#x27;</span> || s &lt; <span class="number">1</span>) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`s() argument must be a positive number, got <span class="subst">$&#123;s&#125;</span>`</span>)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> (s * <span class="number">1000</span>) <span class="keyword">as</span> <span class="title class_">Milliseconds</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>We need to import this function from our spec to use</p><figure class="highlight ts"><figcaption><span>cypress/e2e/misc/wait-ms.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; seconds &#125; <span class="keyword">from</span> <span class="string">&#x27;..&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Waiting with branded types&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;waits using seconds and ms&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// write equivalent to &quot;cy.wait(500)&quot;</span></span><br><span class="line">    <span class="comment">// using explicit branded type &quot;Milliseconds&quot;</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="number">500</span> <span class="keyword">as</span> <span class="title class_">Milliseconds</span>)</span><br><span class="line">    <span class="comment">// wait 3 seconds</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="title function_">seconds</span>(<span class="number">3</span> <span class="keyword">as</span> <span class="title class_">Seconds</span>))</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Again, we need to be explicit when introducing <code>3</code> - it is a value of seconds.</p><h2><span id="branded-type-predicate">Branded type predicate</span></h2><p>If we want to allow any positive number to be used as milliseconds, we could use a &quot;type predicate&quot;</p><figure class="highlight ts"><figcaption><span>cypress/e2e/index.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Branded type predicate function to let TypeScript know</span></span><br><span class="line"><span class="comment"> * that the given number is of type Milliseconds and is less than 10 minutes.</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> n Number to check</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@returns</span> true if the number is Milliseconds</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@example</span></span></span><br><span class="line"><span class="comment"> * ```ts</span></span><br><span class="line"><span class="comment"> * const n = 5000</span></span><br><span class="line"><span class="comment"> * if (isMilliseconds(n)) &#123;</span></span><br><span class="line"><span class="comment"> *   // n is now of type Milliseconds</span></span><br><span class="line"><span class="comment"> *   // there should be no type error</span></span><br><span class="line"><span class="comment"> *   cy.delay(n)</span></span><br><span class="line"><span class="comment"> * &#125;</span></span><br><span class="line"><span class="comment"> * ```</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">isMilliseconds</span>(<span class="params">n: <span class="built_in">number</span></span>): n is <span class="title class_">Milliseconds</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="keyword">typeof</span> n === <span class="string">&#x27;number&#x27;</span> &amp;&amp; n &gt; <span class="number">0</span> &amp;&amp; n &lt; <span class="number">600_000</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The part <code>n is Milliseconds</code> tells TypeScript that if the function returns true, the argument can be used as type <code>Milliseconds</code>. Thus in the <code>if</code> branch below, the <code>n</code> value can by used with <code>cy.delay(n)</code> command without a type error.</p><figure class="highlight ts"><figcaption><span>cypress/e2e/misc/wait-ms.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; seconds, isMilliseconds &#125; <span class="keyword">from</span> <span class="string">&#x27;..&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Waiting with branded types&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;waits using seconds and ms&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// write equivalent to &quot;cy.wait(500)&quot;</span></span><br><span class="line">    <span class="comment">// using explicit branded type &quot;Milliseconds&quot;</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="number">500</span> <span class="keyword">as</span> <span class="title class_">Milliseconds</span>)</span><br><span class="line">    <span class="comment">// wait 3 seconds</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="title function_">seconds</span>(<span class="number">3</span> <span class="keyword">as</span> <span class="title class_">Seconds</span>))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> n = <span class="number">3_000</span></span><br><span class="line">    <span class="keyword">if</span> (<span class="title function_">isMilliseconds</span>(n)) &#123;</span><br><span class="line">      <span class="comment">// n is now of type Milliseconds</span></span><br><span class="line">      cy.<span class="title function_">delay</span>(n)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Here is what the TS IntelliSense shows on the <code>const n = 3_000</code> line</p><p><img src="../images/branded-types/n1.png" alt="Variable n is declared as a number"></p><p>And here is what TS IntelliSense shows inside the <code>if (isMilliseconds(n))</code> branch</p><p><img src="../images/branded-types/n2.png" alt="Variable n is Milliseconds"></p><h2><span id="branded-type-assertion">Branded type assertion</span></h2><p>Using <code>if (isMilliseconds(n))</code>  every time we want to type <code>n</code> as milliseconds is tiresome. Let&#39;s add one more utility function to assert that a given argument is of type <code>Milliseconds</code></p><figure class="highlight ts"><figcaption><span>cypress/e2e/index.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Asserts that the given number is of type Milliseconds</span></span><br><span class="line"><span class="comment"> * and is less than 10 minutes.</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> n Number to check</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@example</span></span></span><br><span class="line"><span class="comment"> * ```ts</span></span><br><span class="line"><span class="comment"> * const n = 5000</span></span><br><span class="line"><span class="comment"> * assertMilliseconds(n)</span></span><br><span class="line"><span class="comment"> * // n is now of type Milliseconds</span></span><br><span class="line"><span class="comment"> * // there should be no type error</span></span><br><span class="line"><span class="comment"> * cy.delay(n)</span></span><br><span class="line"><span class="comment"> * ```</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">assertMilliseconds</span>(<span class="params">n: <span class="built_in">number</span></span>): asserts n is <span class="title class_">Milliseconds</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="keyword">typeof</span> n !== <span class="string">&#x27;number&#x27;</span> || n &lt; <span class="number">1</span> || n &gt;= <span class="number">600_000</span>) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`Expected positive number for Milliseconds, got <span class="subst">$&#123;n&#125;</span>`</span>)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The crucial part is the <code>asserts n is Milliseconds</code> syntax. Let&#39;s use this function in our spec</p><figure class="highlight ts"><figcaption><span>cypress/e2e/misc/wait-ms.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; seconds, assertMilliseconds &#125; <span class="keyword">from</span> <span class="string">&#x27;..&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Waiting with branded types&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;waits using seconds and ms&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// write equivalent to &quot;cy.wait(500)&quot;</span></span><br><span class="line">    <span class="comment">// using explicit branded type &quot;Milliseconds&quot;</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="number">500</span> <span class="keyword">as</span> <span class="title class_">Milliseconds</span>)</span><br><span class="line">    <span class="comment">// wait 3 seconds</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(<span class="title function_">seconds</span>(<span class="number">3</span> <span class="keyword">as</span> <span class="title class_">Seconds</span>))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> n = <span class="number">3_000</span></span><br><span class="line">    <span class="title function_">assertMilliseconds</span>(n)</span><br><span class="line">    <span class="comment">// n is now of type Milliseconds</span></span><br><span class="line">    cy.<span class="title function_">delay</span>(n)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Hover over <code>n</code> after the <code>assertMilliseconds(n)</code> line to confirm that TypeScript &quot;knows&quot; that <code>n</code> is <code>Milliseconds</code> branded type now</p><p><img src="../images/branded-types/asserts.png" alt="TypeScript views variable n as having type Milliseconds after the assertion"></p><p>The branded types approach works for resolving confusion when using currency, number of machines, ids, etc in your specs. Of course, there are libraries that implement branded types so you do not have to create your own: <a href="https://github.com/kourge/ts-brand">ts-brand</a> and <a href="https://effect.website/docs/guides/style/branded-types">Effect</a>.</p><blockquote class="pullquote"><p>😅 Fun fact: I was so sure I wrote this blog post about branded types many years ago, that I kept searching my blog posts and repos for it in vain. &quot;I remember reading about branded types and writing my own take on using them in end-to-end specs, I am sure my blog has it&quot; - until an exhaustive check showed that I simply read about branded types, but never wrote down my own take.</p></blockquote><h2><span id="learn-more">Learn more</span></h2><p>Check out my blog post <a href="/blog/string-types-for-e2e-tests/" title="String Types For E2E Tests">String Types For E2E Tests</a>.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Let&amp;#39;s say you need to specify a timeout or wait in your end-to-end Cypress tests. You would use milliseconds&lt;/p&gt;
&lt;figure class=&quot;highl
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="typescript" scheme="https://glebbahmutov.com/blog/tags/typescript/"/>
    
  </entry>
  
  <entry>
    <title>Cypress vs Playwright Advent Calendar 2025</title>
    <link href="https://glebbahmutov.com/blog/cypress-vs-playwright-advent-calendar-2025/"/>
    <id>https://glebbahmutov.com/blog/cypress-vs-playwright-advent-calendar-2025/</id>
    <published>2025-12-01T05:00:00.000Z</published>
    <updated>2025-12-26T16:22:30.380Z</updated>
    
    <content type="html"><![CDATA[<p>This year I am producing &quot;Cypress vs Playwright Advent Calendar&quot; on my <a href="https://cypresstips.substack.com/">Cypress Tips newsletter</a>.</p><ul><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-1">Day 1: global vs local variables</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-2">Day 2: The difference in test command syntax</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-3">Day 3: DOM Snapshots vs live application</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-4">Day 4: Where do the end-to-end tests run?</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-5">Day 5: Locators and selectors</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-6">Day 6: Composite locators vs query chains</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-7">Day 7: Comparing the test runner release frequencies</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-8">Day 8: Completing todo items and checking CSS classes</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-9">Day 9: Waiting for page elements</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-10">Day 10: Make a network call before each test to rest the backend data</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-11">Day 11: Using API calls rather than UI to set up the initial test state</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-12">Day 12: Use data from a JSON fixture file in your test</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-13">Day 13: Hovering over an item before deleting it</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-14">Day 14: Checking the text of multiple elements</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-15">Day 15: Waiting for a network call to happen</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-16">Day 16: Check the data sent by the server to the browser</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-17">Day 17: Stub the GET &#x2F;todos network call</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-18">Day 18: Stub GET and POST &#x2F;todos network calls</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-19">Day 19: Check the sent Todo object</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-20">Day 20: Comparing the documentation sites</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-21">Day 21: Show test steps a little bit better</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-22">Day 22: Automatic waiting for elements before clicking</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-23">Day 23: Speed up tests by controlling the app&#39;s clock</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-24">Day 24: Component testing</a></li><li><a href="https://cypresstips.substack.com/p/cypress-vs-playwright-advent-calendar-day-25">Day 25: Playwright cannot do sync prop calls in component tests, Cypress can</a></li></ul><p>This advent calendar is based on my online course “<a href="https://cypress.tips/courses/cypress-vs-playwright">Cypress vs Playwright</a>“ and open-source workshop <a href="https://github.com/bahmutov/cypress-workshop-cy-vs-pw">bahmutov&#x2F;cypress-workshop-cy-vs-pw</a>.</p><h2><span id="promo-code">Promo code 💸</span></h2><p>If you like this advent calendar, you will love the full “Cypress vs Playwright” online course, or any of my other end-to-end testing courses. To express my gratitude, I created a 25% discount code <strong>ADVENT25</strong> applied to all courses until Jan 1st, 2026.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;This year I am producing &amp;quot;Cypress vs Playwright Advent Calendar&amp;quot; on my &lt;a href=&quot;https://cypresstips.substack.com/&quot;&gt;Cypress Tips
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="playwright" scheme="https://glebbahmutov.com/blog/tags/playwright/"/>
    
  </entry>
  
  <entry>
    <title>Verify Then Control The Data</title>
    <link href="https://glebbahmutov.com/blog/verify-then-control-the-data/"/>
    <id>https://glebbahmutov.com/blog/verify-then-control-the-data/</id>
    <published>2025-11-30T05:00:00.000Z</published>
    <updated>2025-12-01T22:21:37.519Z</updated>
    
    <content type="html"><![CDATA[<p>Imagine we are testing a web page that receives two numbers from the server and then shows their sum</p><p><img src="../images/verify-then-control-the-data/app.png" alt="App shows the sum of two numbers"></p><p>In the Command Log you can see the two <code>GET /random-digit</code> calls the web page makes to the server. The server responds with <code>&#123;n: ...&#125;</code> JSON object. The page should add these two numbers and show the correct sum</p><p><img src="../images/verify-then-control-the-data/random-digit.png" alt="GET /random-digit response"></p><p>How would you test this page? I see 4 different approaches</p><p><img src="../images/verify-then-control-the-data/check-then-stub.jpg" alt="4 different approaches to checking the sum of numbers sent by the server"></p><blockquote class="pullquote"><p>🎓 This blog post is based on the lessons from my <a href="https://cypress.tips/courses">Cypress Network Testing Exercises</a> course.</p></blockquote><h2><span id="compute-the-data-in-the-test">Compute the data in the test</span></h2><p>The simplest approach we can take is to look up the numbers shown on the page and compute the expected sum inside the test</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;checks numbers shown on the page&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/add/index.html&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#addition.loaded&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// get the two numbers using ids &quot;num1&quot; and &quot;num2&quot;</span></span><br><span class="line">    <span class="comment">// and convert their text to integers</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num1&#x27;</span>)</span><br><span class="line">      .<span class="title function_">invoke</span>(<span class="string">&#x27;text&#x27;</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="built_in">parseInt</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="function">(<span class="params">a</span>) =&gt;</span> &#123;</span><br><span class="line">        cy.<span class="title function_">get</span>(<span class="string">&#x27;#num2&#x27;</span>)</span><br><span class="line">          .<span class="title function_">invoke</span>(<span class="string">&#x27;text&#x27;</span>)</span><br><span class="line">          .<span class="title function_">then</span>(<span class="built_in">parseInt</span>)</span><br><span class="line">          .<span class="title function_">then</span>(<span class="function">(<span class="params">b</span>) =&gt;</span> &#123;</span><br><span class="line">            <span class="comment">// compute the sum ourselves</span></span><br><span class="line">            <span class="keyword">const</span> sum = a + b</span><br><span class="line">            cy.<span class="title function_">get</span>(<span class="string">&#x27;#sum&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, sum)</span><br><span class="line">          &#125;)</span><br><span class="line">      &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/verify-then-control-the-data/test1.png" alt="Solution 1: read the page"></p><p>This approach has drawbacks</p><ul><li>we trust the page to show the numbers correctly</li><li>we compute the expected sum inside the test</li><li>there is a pyramid of Doom of nested <code>cy.then</code> callbacks as we extract each number</li></ul><p>My rule of thumb is to never trust the page to show the data correctly and avoid duplicating app logic inside the test. Computing the sum inside the test is one such duplication example.</p><h2><span id="get-the-data-from-the-network-calls">Get the data from the network calls</span></h2><p>Instead of looking up the numbers in the DOM, let&#39;s grab the numbers sent by the server by spying on the network calls <code>GET /random-digit</code></p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;checks the numbers using API traffic&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">intercept</span>(<span class="string">&#x27;/random-digit&#x27;</span>).<span class="title function_">as</span>(<span class="string">&#x27;randomDigit&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/add/index.html&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">wait</span>([<span class="string">&#x27;@randomDigit&#x27;</span>, <span class="string">&#x27;@randomDigit&#x27;</span>]).<span class="title function_">spread</span>(</span><br><span class="line">    <span class="function">(<span class="params">intercept1, intercept2</span>) =&gt;</span> &#123;</span><br><span class="line">      <span class="keyword">const</span> a = intercept1.<span class="property">response</span>.<span class="property">body</span>.<span class="property">n</span></span><br><span class="line">      <span class="keyword">const</span> b = intercept2.<span class="property">response</span>.<span class="property">body</span>.<span class="property">n</span></span><br><span class="line">      <span class="comment">// compute the sum ourselves</span></span><br><span class="line">      <span class="keyword">const</span> sum = a + b</span><br><span class="line">      <span class="comment">// confirm the sum shown on the page is correct</span></span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;#sum&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, sum)</span><br><span class="line">    &#125;,</span><br><span class="line">  )</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/verify-then-control-the-data/test2.png" alt="Solution 2: spy on the network calls"></p><p>This solution is better</p><ul><li>no more querying the page</li><li>simpler test syntax</li></ul><p>Still, the test is non-deterministic and we compute the sum inside the test, complicating the test logic</p><h2><span id="stub-the-network-calls">Stub the network calls</span></h2><p>Instead of spying on the network calls, let&#39;s stub them and return deterministic data to the app</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;controls the numbers received by the page&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123; <span class="attr">n</span>: <span class="number">7</span> &#125;,</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;secondNumber&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123; <span class="attr">n</span>: <span class="number">3</span> &#125;,</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;firstNumber&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/add/index.html&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">wait</span>([<span class="string">&#x27;@firstNumber&#x27;</span>, <span class="string">&#x27;@secondNumber&#x27;</span>])</span><br><span class="line">  <span class="comment">// confirm the two numbers are shown correctly on the page</span></span><br><span class="line">  <span class="comment">// confirm the sum shown on the page is correct</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#addition.loaded&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num1&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;3&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num2&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;7&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#sum&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;10&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/verify-then-control-the-data/test3.png" alt="Solution 3: mock the network calls"></p><p>This solution is <em>much</em> simpler. We do not need <code>cy.then</code> callbacks, all assertions are easy to understand, there is no computation in the test. But this test has one drawback: if the API call <code>GET /random-digit</code> changes, we STILL will return <code>&#123;n: ...&#125;</code> objects.</p><h2><span id="verify-then-control-the-data">Verify then control the data</span></h2><p>My last solution will add a schema check to verify what the server sends, before returning mock data to the web application.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;verifies the server data and controls the numbers received by the page&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="function">(<span class="params">req</span>) =&gt;</span></span><br><span class="line">      req.<span class="title function_">continue</span>(<span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="title function_">expect</span>(res.<span class="property">body</span>, <span class="string">&#x27;response body&#x27;</span>).<span class="property">to</span>.<span class="property">have</span>.<span class="title function_">keys</span>([</span><br><span class="line">          <span class="string">&#x27;n&#x27;</span>,</span><br><span class="line">        ])</span><br><span class="line">        <span class="title function_">expect</span>(res.<span class="property">body</span>.<span class="property">n</span>, <span class="string">&#x27;server number&#x27;</span>).<span class="property">to</span>.<span class="property">be</span>.<span class="title function_">within</span>(</span><br><span class="line">          <span class="number">0</span>,</span><br><span class="line">          <span class="number">10</span>,</span><br><span class="line">        )</span><br><span class="line">        res.<span class="property">body</span> = &#123; <span class="attr">n</span>: <span class="number">7</span> &#125;</span><br><span class="line">      &#125;),</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;secondNumber&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="function">(<span class="params">req</span>) =&gt;</span></span><br><span class="line">      req.<span class="title function_">continue</span>(<span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="title function_">expect</span>(res.<span class="property">body</span>, <span class="string">&#x27;response body&#x27;</span>).<span class="property">to</span>.<span class="property">have</span>.<span class="title function_">keys</span>([</span><br><span class="line">          <span class="string">&#x27;n&#x27;</span>,</span><br><span class="line">        ])</span><br><span class="line">        <span class="title function_">expect</span>(res.<span class="property">body</span>.<span class="property">n</span>, <span class="string">&#x27;server number&#x27;</span>).<span class="property">to</span>.<span class="property">be</span>.<span class="title function_">within</span>(</span><br><span class="line">          <span class="number">0</span>,</span><br><span class="line">          <span class="number">10</span>,</span><br><span class="line">        )</span><br><span class="line">        res.<span class="property">body</span> = &#123; <span class="attr">n</span>: <span class="number">3</span> &#125;</span><br><span class="line">      &#125;),</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;firstNumber&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/add/index.html&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">wait</span>([<span class="string">&#x27;@firstNumber&#x27;</span>, <span class="string">&#x27;@secondNumber&#x27;</span>])</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#addition.loaded&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num1&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;3&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num2&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;7&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#sum&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;10&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Of course, we can refactor the response check into a utility function for simplicity</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">checkAndStub</span>(<span class="params">body</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="function">(<span class="params">req</span>) =&gt;</span></span><br><span class="line">    req.<span class="title function_">continue</span>(<span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">      <span class="title function_">expect</span>(res.<span class="property">body</span>, <span class="string">&#x27;response body&#x27;</span>).<span class="property">to</span>.<span class="property">have</span>.<span class="title function_">keys</span>([</span><br><span class="line">        <span class="string">&#x27;n&#x27;</span>,</span><br><span class="line">      ])</span><br><span class="line">      <span class="title function_">expect</span>(res.<span class="property">body</span>.<span class="property">n</span>, <span class="string">&#x27;server number&#x27;</span>).<span class="property">to</span>.<span class="property">be</span>.<span class="title function_">within</span>(</span><br><span class="line">        <span class="number">0</span>,</span><br><span class="line">        <span class="number">10</span>,</span><br><span class="line">      )</span><br><span class="line">      res.<span class="property">body</span> = body</span><br><span class="line">    &#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;verifies the server data and controls the numbers received by the page (refactored)&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="title function_">checkAndStub</span>(&#123; <span class="attr">n</span>: <span class="number">7</span> &#125;),</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;secondNumber&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">intercept</span>(</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">pathname</span>: <span class="string">&#x27;/random-digit&#x27;</span>,</span><br><span class="line">      <span class="attr">times</span>: <span class="number">1</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="title function_">checkAndStub</span>(&#123; <span class="attr">n</span>: <span class="number">3</span> &#125;),</span><br><span class="line">  ).<span class="title function_">as</span>(<span class="string">&#x27;firstNumber&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/add/index.html&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">wait</span>([<span class="string">&#x27;@firstNumber&#x27;</span>, <span class="string">&#x27;@secondNumber&#x27;</span>])</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#addition.loaded&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num1&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;3&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#num2&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;7&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#sum&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;10&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/verify-then-control-the-data/test4.png" alt="Solution 4: verify the server response before substituting our mock data"></p><p>I really like this approach. We verify the server response to follow the expected schema. Then we substitute our own mock data to return to the application. All is left is to check what the page computes and shows.</p><h2><span id="see-also">See also</span></h2><ul><li><a href="/blog/mock-but-verify/" title="Mock But Verify">Mock But Verify</a></li></ul>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Imagine we are testing a web page that receives two numbers from the server and then shows their sum&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../images/verify-th
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Copilot Instructions Example</title>
    <link href="https://glebbahmutov.com/blog/copilot-instructions-example/"/>
    <id>https://glebbahmutov.com/blog/copilot-instructions-example/</id>
    <published>2025-10-09T04:00:00.000Z</published>
    <updated>2025-10-09T18:09:16.250Z</updated>
    
    <content type="html"><![CDATA[<p>I have described using <a href="/blog/copilot-instructions-and-page-objects/" title="Use Copilot Instructions And Page Objects">Use Copilot Instructions And Page Objects</a> to quickly develop end-to-end Cypress tests. This blog post gives another concrete example. You can find the source code in the repo <a href="https://github.com/bahmutov/copilot-instructions-example">bahmutov&#x2F;copilot-instructions-example</a>. First, I wrote a page object file to implement common TodoMVC application test commands:</p><ul><li>visit the app and wait for it to load</li><li>reset the backend data to zero todos or the given number of items</li></ul><figure class="highlight js"><figcaption><span>cypress/e2e/todomvc.po.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">TodoMVC</span> = &#123;</span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Visits the app page and confirms it has loaded</span></span><br><span class="line"><span class="comment">   * <span class="doctag">@example</span> TodoMVC.visit()</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="title function_">visit</span>(<span class="params"></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">step</span>(<span class="string">&#x27;visit the app&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;body.loaded&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Resets the backend to have zero todos</span></span><br><span class="line"><span class="comment">   * <span class="doctag">@example</span> TodoMVC.reset()</span></span><br><span class="line"><span class="comment">   *</span></span><br><span class="line"><span class="comment">   * You can reset the data to have specific todos</span></span><br><span class="line"><span class="comment">   * <span class="doctag">@example</span></span></span><br><span class="line"><span class="comment">   * TodoMVC.reset([</span></span><br><span class="line"><span class="comment">   *   &#123; id: &#x27;1&#x27;, title: &#x27;first task&#x27;, completed: false &#125;,</span></span><br><span class="line"><span class="comment">   *   &#123; id: &#x27;2&#x27;, title: &#x27;second task&#x27;, completed: true &#125;,</span></span><br><span class="line"><span class="comment">   * ])</span></span><br><span class="line"><span class="comment">   * <span class="doctag">@param</span> &#123;<span class="type">Array&lt;&#123;id: string, title: string, completed: boolean</span>&#125;&gt;&#125; todos</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="title function_">reset</span>(<span class="params">todos = []</span>) &#123;</span><br><span class="line">    cy.<span class="title function_">step</span>(<span class="string">`reset the backend with <span class="subst">$&#123;todos.length&#125;</span> todos`</span>)</span><br><span class="line">    cy.<span class="title function_">request</span>(<span class="string">&#x27;POST&#x27;</span>, <span class="string">&#x27;/reset&#x27;</span>, &#123; todos &#125;)</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>I do not use page objects to wrap <em>every</em> possible DOM element and assertion. I prefer implementing high-level app operations that might be relevant to many tests. Specific page selectors and checks could reside inside the specific tests.</p><p>My Copilot instructions file <code>.github/copilot-instructions.md</code> lists common testing tasks and how I would like to implement them:</p><pre><code>## Use the TodoMVC page objectPreferred way is to use the TodoMVC page object from `cypress/e2e/todomvc.po.js`<figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">TodoMVC</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;./todomvc.po&#x27;</span></span><br><span class="line"><span class="comment">// inside the test or beforeEach hook</span></span><br><span class="line"><span class="title class_">TodoMVC</span>.<span class="title function_">visit</span>()</span><br></pre></td></tr></table></figure>## Reset the backendTest can reset the backend data to zero todos state using the following commands<figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">request</span>(<span class="string">&#x27;POST&#x27;</span>, <span class="string">&#x27;/reset&#x27;</span>, &#123; <span class="attr">todos</span>: [] &#125;)</span><br></pre></td></tr></table></figure>## Application loadedTest can confirm the application has finished loading<figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">get</span>(<span class="string">&#x27;body.loaded&#x27;</span>)</span><br></pre></td></tr></table></figure>## Set the backend dataYou can set the backend to have specific todos before visiting the app. Let&#39;s set 2 todos. Each todo must have an `id`, `title`, and `completed` status.<figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">request</span>(<span class="string">&#x27;POST&#x27;</span>, <span class="string">&#x27;/reset&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">todos</span>: [</span><br><span class="line">    &#123; <span class="attr">id</span>: <span class="string">&#x27;1&#x27;</span>, <span class="attr">title</span>: <span class="string">&#x27;learn testing&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">    &#123; <span class="attr">id</span>: <span class="string">&#x27;2&#x27;</span>, <span class="attr">title</span>: <span class="string">&#x27;learn cypress&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">  ],</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure>Preferably, use the page object method<figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">TodoMVC</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;./todomvc.po&#x27;</span></span><br><span class="line"><span class="comment">// inside the test or beforeEach hook</span></span><br><span class="line"><span class="title class_">TodoMVC</span>.<span class="title function_">reset</span>([</span><br><span class="line">  &#123; <span class="attr">id</span>: <span class="string">&#x27;1&#x27;</span>, <span class="attr">title</span>: <span class="string">&#x27;learn testing&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">  &#123; <span class="attr">id</span>: <span class="string">&#x27;2&#x27;</span>, <span class="attr">title</span>: <span class="string">&#x27;learn cypress&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">])</span><br></pre></td></tr></table></figure></code></pre><p>Copilot Agent uses the instructions file to provide inline suggestions and answer prompts. For example, without the instructions file, the suggestions on visiting the page is very generic:</p><p><img src="../images/copilot-instructions-example/generic.png" alt="Generic GPT-5 TodoMVC suggestion"></p><p>The suggested check for zero <code>LI</code> elements is incorrect - it will immediately pass, even while the application is loading. Let&#39;s put our Copilot instruction file back in. Here is the same place, notice the correct suggestion:</p><p><img src="../images/copilot-instructions-example/visit.png" alt="Custom page visit command that implements the correct load check"></p><p>That is the right way to visit the page, since it correctly waits for the app to set the <code>loaded</code> class:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">TodoMVC</span> = &#123;</span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Visits the app page and confirms it has loaded</span></span><br><span class="line"><span class="comment">   * <span class="doctag">@example</span> TodoMVC.visit()</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="title function_">visit</span>(<span class="params"></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">step</span>(<span class="string">&#x27;visit the app&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;body.loaded&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line">  ...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Copilot Agent mode is even more powerful, in the same spec I can ask it to implement the <code>// visit the page and wait for it to load</code> comment and here is what it does:</p><p><img src="../images/copilot-instructions-example/agent.png" alt="Copilot Agent correctly uses our page object to write a great test"></p><p>Since we do not need to include lots of context with our prompts, we can effectively use short voice prompts. Here is a screen recording with me asking the Copilot Agent to refactor the current spec to use the page object:</p><center>  <video autoplay muted loop controls>    <source src="../images/copilot-instructions-example/voice.mp4" type="video/mp4">    <img src="../images/copilot-instructions-example/voice.png">  </video></center><p>Worked really nicely, the refactored test is passing.</p><p>By the way, in most of my projects I use plugins <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> and <a href="https://github.com/filiphric/cypress-plugin-steps">cypress-plugin-steps</a>, imported from the support file</p><figure class="highlight js"><figcaption><span>cypress/support/e2e.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// https://github.com/bahmutov/cypress-map</span></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-map&#x27;</span></span><br><span class="line"><span class="comment">// https://github.com/filiphric/cypress-plugin-steps</span></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-plugin-steps&#x27;</span></span><br></pre></td></tr></table></figure><p>Using the <code>cypress-plugin-steps</code> especially makes the test logs easier to read:</p><p><img src="../images/copilot-instructions-example/app.png" alt="Clear test steps using cypress-plugin-steps inside the page object methods"></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I have described using &lt;a href=&quot;/blog/copilot-instructions-and-page-objects/&quot; title=&quot;Use Copilot Instructions And Page Objects&quot;&gt;Use Copil
      
    
    </summary>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/categories/advice/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="copilot" scheme="https://glebbahmutov.com/blog/tags/copilot/"/>
    
  </entry>
  
  <entry>
    <title>Never Use The Page As The Source Of Truth</title>
    <link href="https://glebbahmutov.com/blog/source-of-truth/"/>
    <id>https://glebbahmutov.com/blog/source-of-truth/</id>
    <published>2025-10-03T04:00:00.000Z</published>
    <updated>2025-10-03T15:26:14.172Z</updated>
    
    <content type="html"><![CDATA[<p>Imagine you are testing a web page showing the purchase receipt.</p><p><img src="../images/source-of-truth/receipt.png" alt="The receipt page test"></p><p>When the user clicks on the &quot;View Summary&quot; button, a dialog pops up showing just the total amount paid</p><figure class="highlight js"><figcaption><span>cypress/e2e/receipt.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows the price on the receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;app/index.html&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;View Summary&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/source-of-truth/total.png" alt="The total amount paid popup"></p><p>How would you confirm that the total $ shown in the summary is the same as the total amount shown in the popup dialog?</p><p><img src="../images/source-of-truth/equal.png" alt="Are these numbers equal?"></p><p>A typical test would simply grab one amount and check if the second element has the same text</p><figure class="highlight js"><figcaption><span>cypress/e2e/receipt.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows the price on the receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;app/index.html&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#grand-total&#x27;</span>)</span><br><span class="line">      .<span class="title function_">invoke</span>(<span class="string">&#x27;text&#x27;</span>)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;be.a&#x27;</span>, <span class="string">&#x27;string&#x27;</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="function">(<span class="params">totalText</span>) =&gt;</span> &#123;</span><br><span class="line">        cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;View Summary&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">        cy.<span class="title function_">get</span>(<span class="string">&#x27;#dialog-total-value&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, totalText)</span><br><span class="line">      &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/source-of-truth/test1.png" alt="A naive approach checks if one element has the same text as another element"></p><p>This is a bad idea. You are using the page as the source of truth. You are trusting the first element to be correct, without verifying it. For example, both elements might <em>not</em> shown any meaningful numbers!</p><p><img src="../images/source-of-truth/undefined.png" alt="The test is still passing"></p><p>Oops, the two elements show the <em>same</em> meaningless text.</p><p>Comparing elements&#39; text is also prone to trip up on formatting and display differences. For example, one element could put the <code>$</code> character outside its span element.</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- the summary row --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">span</span> <span class="attr">id</span>=<span class="string">&quot;grand-total&quot;</span>&gt;</span>$304.97<span class="tag">&lt;/<span class="name">span</span>&gt;</span></span><br><span class="line"><span class="comment">&lt;!-- the order popup --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">p</span> <span class="attr">class</span>=<span class="string">&quot;dialog-total-value&quot;</span>&gt;</span></span><br><span class="line">  $ <span class="tag">&lt;<span class="name">span</span> <span class="attr">id</span>=<span class="string">&quot;dialog-total-value&quot;</span>&gt;</span>304.97<span class="tag">&lt;/<span class="name">span</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">p</span>&gt;</span></span><br></pre></td></tr></table></figure><p>To the user, both totals are correct. But the test fails.</p><p><img src="../images/source-of-truth/fails.png" alt="The test fails because of different formatting"></p><p>Instead of trusting the web page to be correct, let the test itself be the source of the ground truth. Stub the data, stub the network call, control the data loaded by the page - any technique is good. Even a little bit of duplication is ok in the testing code as long as the test intention is clear and easy to maintain.</p><figure class="highlight js"><figcaption><span>cypress/e2e/receipt.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows the price on the receipt&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;app/index.html&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#grand-total&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;$304.97&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;View Summary&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#dialog-total-value&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;304.97&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The test is passing, does not trust the page to show the correct value, and is <em>easier</em> to read, since we avoid using a <code>cy.then</code> callback - something I always advocated, see the video <a href="https://www.youtube.com/watch?v=X8iIoTxu_8k">Good Cypress Test Syntax</a> for example.</p><p><img src="../images/source-of-truth/pass.png" alt="The test uses itself as the source of truth"></p><p>Solid.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Imagine you are testing a web page showing the purchase receipt.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../images/source-of-truth/receipt.png&quot; alt=&quot;The receipt
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Cypress vs Playwright; Browser Included</title>
    <link href="https://glebbahmutov.com/blog/cy-vs-pw-browser/"/>
    <id>https://glebbahmutov.com/blog/cy-vs-pw-browser/</id>
    <published>2025-09-30T04:00:00.000Z</published>
    <updated>2025-09-30T15:41:49.461Z</updated>
    
    <content type="html"><![CDATA[<p>Recently I watched a pretty good video <a href="https://www.youtube.com/watch?v=4W7TWu8NmTM">Cypress vs Playwright side-by-side coding comparison.</a> by Artem Bondar. While not as good as my <a href="https://cypress.tips/courses/cypress-vs-playwright">Cypress vs Playwright course</a>, the video is pretty solid. There is one point Artem makes in the beginning that he did not stress enough, but which determines <em>how</em> each tool gives you the access to the browser and your web application under test.</p><h2><span id="the-browser">The browser</span></h2><p>Here is the relevant still from the video:</p><p><img src="../images/cy-vs-pw-browser/not-a-browser.png" alt="Playwright UI mode shows you a recorded trace of your test"></p><h3><span id="playwright-gives-you-dom-snapshots">Playwright gives you DOM snapshots</span></h3><p>Like Artem says, even in the <code>playwright test --ui</code> mode, what you see is NOT the real browser, but the browser showing the trace of the recorded test. So Artem keeps a separate browser open while testing, looking up selectors, interacting with the app, etc. Cumbersome and inefficient, especially if you need to set up the app into a particular state the test needs.</p><p><img src="../images/cy-vs-pw-browser/pw.png" alt="Playwright UI mode shows DOM snapshots for each command"></p><h3><span id="cypress-gives-you-the-live-app-in-the-real-browser">Cypress gives you the live app in the real browser</span></h3><p>On the other hand, Cypress runs its tests in the real browser: Electron, Chrome, Firefox, Webkit. The app is included in its own iframe. You can see the markup right there!</p><p><img src="../images/cy-vs-pw-browser/cy-real-browser.png" alt="Cypress browser shows the real DOM"></p><p>Not just the real DOM thing: you can interact with the application to observe what it does; right here I flip the &quot;Light&quot; switch after the test visits the page.</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/let-there-be-light.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/let-there-be-light.png">  </video></center><p>Let&#39;s say I want to test the color themes. I can click on the &quot;Light&quot; button, inspect the theme selector markup, then quickly write the test.</p><p><img src="../images/cy-vs-pw-browser/themes.png" alt="Inspecting the color theme switch markup"></p><p>Great, got it. Let&#39;s expand our test. To better show the developer experience, I will keep Cypress browser and my VSCode side by side.</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/click.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/click.png">  </video></center><p>Great, the color theme changes from &quot;Light&quot; to &quot;Cosmic&quot;, but how can we confirm it? We can simply inspect the application DOM before and after the click.</p><blockquote class="pullquote"><p>🎁 You can find both Cy and Pw specs in the repo <a href="https://github.com/bahmutov/cy-vs-pw-browser">bahmutov&#x2F;cy-vs-pw-browser</a>.</p></blockquote><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/test-theme.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/test-theme.png">  </video></center><p>Having a real browser showing the app is a super power. Of course, both Cypress and Playwright also have time-traveling debugger showing the DOM for each command, but seeing the real app at the end is <em>efficient</em>.</p><p>Can you see the tests run in the real browser in Playwright. Kind of with <code>npx playwright test --debug --ui</code> command. The <code>--debug</code> option opens a headed browser instance, which runs the tests and closes the browser.</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/pw-debug.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/pw-debug.png">  </video></center><h2><span id="execute-test-commands-one-by-one">Execute test commands one by one</span></h2><p>Both Playwright and Cypress give you a way to execute individual test commands one by one. Playwright has <code>page.pause</code> and Cypress has <code>cy.pause</code> command.</p><p>Same if you use Playwright Inspect which opens if you use the <code>page.pause</code> method:</p><figure class="highlight ts"><figcaption><span>Playwright spec</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">test</span>(<span class="string">&#x27;counts seconds&#x27;</span>, <span class="keyword">async</span> (&#123; page &#125;) =&gt; &#123;</span><br><span class="line">  <span class="keyword">await</span> page.<span class="title function_">goto</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> page.<span class="title function_">pause</span>()</span><br><span class="line">  <span class="keyword">const</span> timer = page.<span class="title function_">locator</span>(<span class="string">&#x27;#timer&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;0&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;1&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;2&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;3&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;4&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">expect</span>(timer).<span class="title function_">toHaveText</span>(<span class="string">&#x27;5&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>I am running PW using the <code>playwright test --ui --debug</code> command. When the test launches, a real browser instance shows, the test is paused and I can step through the commands using the Pw Inspector window:</p><p><img src="../images/cy-vs-pw-browser/inspector.png" alt="Playwright Inspector"></p><p>Cypress has an equivalent <a href="https://on.cypress.io/pause">cy.pause</a> command that let&#39;s you step through the commands one-by-one</p><figure class="highlight js"><figcaption><span>Cypress spec</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;counts seconds&#x27;</span>, &#123; <span class="attr">baseUrl</span>: <span class="string">&#x27;http://localhost:9090&#x27;</span> &#125;, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;0&#x27;</span>).<span class="title function_">pause</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;1&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;2&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;3&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;4&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#timer&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;5&#x27;</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/cy-vs-pw-browser/cy-pause.png" alt="Cypress cy.pause command"></p><p>In both test runners, pausing the test lets the application continue running; the timer keeps ticking every second. If we really want to observe what the application is doing in response to the test command, we MUST pause both the test and the app. Can we do this?</p><h2><span id="debugger-in-the-same-event-loop">Debugger in the same event loop</span></h2><h3><span id="cypress-waits">Cypress waits</span></h3><p>One other interesting aspect of running Cypress tests and the app in the same browser window is that they share the single JavaScript event loop. Which means if you pause the application code using the <code>debugger</code> keyword, the test code pauses too, as this short video shows.</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/debugger-in-app.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/debugger-in-app.png">  </video></center><p><strong>Note:</strong> to pause the app, the DevTools must be opened.</p><p>And vice versa: if you use the Cypress command <a href="https://on.cypress.io/debug">cy.debug</a> while the DevTools is opened, both the tests AND the application pauses. You can inspect the actual app objects, network calls, storage, everything!</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/cy-debug.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/cy-debug.png">  </video></center><h3><span id="playwright-does-not-wait">Playwright does not wait</span></h3><p>Let&#39;s see what Playwright does when there is a <code>debugger</code> keyword in the application code. I will run the browser in the <a href="https://playwright.dev/docs/debug">debug mode</a> to see if the test pauses while the application is paused:</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/pw-does-not-wait.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/pw-does-not-wait.png">  </video></center><p>Nope. While the application is paused, the test runner keeps executing the test and then failing it, since the timer element has not been updated. What about the other way? If the test is paused, does the application stay frozen? We can use VSCode Playwright extension to put a breakpoint on the test line and run the browser in the debug mode.</p><center>  <video autoplay muted loop controls>    <source src="../images/cy-vs-pw-browser/pw-app-does-not-wait.mp4" type="video/mp4">    <img src="../images/cy-vs-pw-browser/pw-app-does-not-wait.png">  </video></center><p>Nope again. The application &quot;keeps ticking&quot; so to speak while the test is frozen at a breakpoint. Trying to debug an application in this way, while the test runner and the app are two separate processes is ... complicated.</p><p>Ok, hope it makes the real difference between Cypress and Playwright a little bit clearer. If you want to learn more, check out my online course <a href="https://cypress.tips/courses/cypress-vs-playwright">Cypress vs Playwright course</a> or even an open-source workshop <a href="https://github.com/bahmutov/cypress-workshop-cy-vs-pw">bahmutov&#x2F;cypress-workshop-cy-vs-pw</a>.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Recently I watched a pretty good video &lt;a href=&quot;https://www.youtube.com/watch?v=4W7TWu8NmTM&quot;&gt;Cypress vs Playwright side-by-side coding co
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="playwright" scheme="https://glebbahmutov.com/blog/tags/playwright/"/>
    
  </entry>
  
  <entry>
    <title>Check Data Using Page Objects And Higher Order Functions</title>
    <link href="https://glebbahmutov.com/blog/check-data-in-page-objects/"/>
    <id>https://glebbahmutov.com/blog/check-data-in-page-objects/</id>
    <published>2025-09-28T04:00:00.000Z</published>
    <updated>2025-09-29T00:02:08.777Z</updated>
    
    <content type="html"><![CDATA[<p>Testers and developers often use page objects to interact with their web applications via DOM elements. Let&#39;s create a page object for our TodoMVC app:</p><figure class="highlight ts"><figcaption><span>cypress/e2e/todo.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">TodoMVC</span> = &#123;</span><br><span class="line">  <span class="title function_">addTodo</span>(<span class="params">title: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">`<span class="subst">$&#123;title&#125;</span>&#123;enter&#125;`</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">getTodos</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>All tests that add a todo item and check them can use the <code>addTodo</code> and <code>getTodos</code> methods</p><figure class="highlight ts"><figcaption><span>cypress/e2e/adding-spec.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">TodoMVC</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;./todo&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;TodoMVC&#x27;</span>, <span class="keyword">function</span> (<span class="params"></span>) &#123;</span><br><span class="line">  <span class="title function_">beforeEach</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;adds a new todo&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Feed the cat&#123;enter&#125;&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;read&#x27;</span>, [<span class="string">&#x27;Feed the cat&#x27;</span>])</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/check-data-in-page-objects/add.png" alt="Adds a new todo test"></p><p><strong>Note:</strong> the <code>should read</code> assertion comes from my <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> plugin.</p><blockquote class="pullquote"><p>🎁 The source code for this blog post can be found in the repo <a href="https://github.com/bahmutov/todomvc-po-with-assertions">bahmutov&#x2F;todomvc-po-with-assertions</a>.</p></blockquote><h2><span id="data-validation">Data validation</span></h2><p>What about data validation? For example, our application stores todo items in the browser&#39;s local storage. Let&#39;s confirm it.</p><p><img src="../images/check-data-in-page-objects/local-storage.png" alt="The app stores its data in a local storage entry"></p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;stores todo in the local storage&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Feed the cat&#123;enter&#125;&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;read&#x27;</span>, [<span class="string">&#x27;Feed the cat&#x27;</span>])</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">step</span>(<span class="string">&#x27;check local storage&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">window</span>()</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;localStorage&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invoke</span>(<span class="string">&#x27;getItem&#x27;</span>, <span class="string">&#x27;react-todos&#x27;</span>)</span><br><span class="line">    .<span class="title function_">apply</span>(<span class="title class_">JSON</span>.<span class="property">parse</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">1</span>)</span><br><span class="line">    .<span class="title function_">its</span>(<span class="number">0</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;deep.include&#x27;</span>, &#123; <span class="attr">title</span>: <span class="string">&#x27;Feed the cat&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/check-data-in-page-objects/check.png" alt="Checking the object stored in the local storage"></p><p><strong>Note:</strong> the <code>cy.step</code> command comes from the <a href="https://github.com/filiphric/cypress-plugin-steps">cypress-plugin-steps</a> plugin, <code>cy.apply</code> query is from my <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> plugin.</p><p>If we want to validate the id of the item, we need to grab the its value and use a regular expression</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">cy.<span class="title function_">step</span>(<span class="string">&#x27;check local storage&#x27;</span>)</span><br><span class="line">cy.<span class="title function_">window</span>()</span><br><span class="line">  .<span class="title function_">its</span>(<span class="string">&#x27;localStorage&#x27;</span>)</span><br><span class="line">  .<span class="title function_">invoke</span>(<span class="string">&#x27;getItem&#x27;</span>, <span class="string">&#x27;react-todos&#x27;</span>)</span><br><span class="line">  .<span class="title function_">apply</span>(<span class="title class_">JSON</span>.<span class="property">parse</span>)</span><br><span class="line">  .<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">1</span>)</span><br><span class="line">  .<span class="title function_">its</span>(<span class="number">0</span>)</span><br><span class="line">  .<span class="title function_">should</span>(<span class="string">&#x27;deep.include&#x27;</span>, &#123; <span class="attr">title</span>: <span class="string">&#x27;Feed the cat&#x27;</span>, <span class="attr">completed</span>: <span class="literal">false</span> &#125;)</span><br><span class="line">  .<span class="title function_">its</span>(<span class="string">&#x27;id&#x27;</span>)</span><br><span class="line">  .<span class="title function_">should</span>(</span><br><span class="line">    <span class="string">&#x27;match&#x27;</span>,</span><br><span class="line">    <span class="regexp">/^[0-9a-f]&#123;8&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;12&#125;$/i</span>,</span><br><span class="line">  )</span><br></pre></td></tr></table></figure><p><img src="../images/check-data-in-page-objects/id.png" alt="Validating the known properties plus the &quot;id&quot; value"></p><h2><span id="cy-spok">cy-spok</span></h2><p>Just like entering a todo item into the input field, checking the data is a very common operation in E2E tests. Thus it makes sense to create a page object utility method to easily check the data. I can use my <a href="https://github.com/bahmutov/cy-spok">cy-spok</a> plugin in the page object to create a function to be used as <code>cy.should(callback)</code> argument.</p><figure class="highlight ts"><figcaption><span>cypress/e2e/todo.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// https://github.com/bahmutov/cy-spok</span></span><br><span class="line"><span class="keyword">import</span> spok <span class="keyword">from</span> <span class="string">&#x27;cy-spok&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">TodoMVC</span> = &#123;</span><br><span class="line">  <span class="title function_">addTodo</span>(<span class="params">title: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">`<span class="subst">$&#123;title&#125;</span>&#123;enter&#125;`</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">getTodos</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="attr">beTodoItem</span>: <span class="title function_">spok</span>(&#123;</span><br><span class="line">    <span class="attr">title</span>: <span class="string">&#x27;Feed the cat&#x27;</span>,</span><br><span class="line">    <span class="attr">completed</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">id</span>: spok.<span class="title function_">test</span>(</span><br><span class="line">      <span class="regexp">/^[0-9a-f]&#123;8&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;12&#125;$/i</span>,</span><br><span class="line">    ),</span><br><span class="line">  &#125;),</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The <code>spok</code> is a higher order function, since calling <code>spok(&#123; ... &#125;)</code> returns another function. The property <code>beTodoItem: spok(&#123; ... &#125;)</code> is a method to be used inside <code>cy.should(callback)</code>. Let&#39;s check our local storage</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;adds a new todo (check the data)&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title class_">TodoMVC</span>.<span class="title function_">addTodo</span>(<span class="string">&#x27;Feed the cat&#x27;</span>)</span><br><span class="line">  <span class="title class_">TodoMVC</span>.<span class="title function_">getTodos</span>().<span class="title function_">should</span>(<span class="string">&#x27;read&#x27;</span>, [<span class="string">&#x27;Feed the cat&#x27;</span>])</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">step</span>(<span class="string">&#x27;check local storage&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">window</span>()</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;localStorage&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invoke</span>(<span class="string">&#x27;getItem&#x27;</span>, <span class="string">&#x27;react-todos&#x27;</span>)</span><br><span class="line">    .<span class="title function_">apply</span>(<span class="title class_">JSON</span>.<span class="property">parse</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">1</span>)</span><br><span class="line">    .<span class="title function_">its</span>(<span class="number">0</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="title class_">TodoMVC</span>.<span class="property">beTodoItem</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/check-data-in-page-objects/should-spok.png" alt="Page object with a should object callback"></p><h2><span id="checking-the-server-response">Checking the server response</span></h2><p>Let&#39;s say our application receives the todo items from the backend. We could use the same callback property together with <code>cy.intercept</code> command</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;sends the todos on load&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title class_">TodoMVC</span>.<span class="title function_">addTodo</span>(<span class="string">&#x27;Feed the cat&#x27;</span>)</span><br><span class="line">  <span class="title class_">TodoMVC</span>.<span class="title function_">getTodos</span>().<span class="title function_">should</span>(<span class="string">&#x27;read&#x27;</span>, [<span class="string">&#x27;Feed the cat&#x27;</span>])</span><br><span class="line">  cy.<span class="title function_">intercept</span>(<span class="string">&#x27;GET&#x27;</span>, <span class="string">&#x27;/todos&#x27;</span>).<span class="title function_">as</span>(<span class="string">&#x27;getData&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">reload</span>()</span><br><span class="line">  cy.<span class="title function_">wait</span>(<span class="string">&#x27;@getData&#x27;</span>)</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;response.body.0&#x27;</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="title class_">TodoMVC</span>.<span class="property">beTodoItem</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2><span id="checking-html-structure">Checking HTML structure</span></h2><p>Custom data checks inside the page object - how about custom HTML checks inside the page object? Let&#39;s say we want to validate all important fields inside the page. We could use a combination of <code>cy.get</code> and <code>cy.within</code> and all other HTML assertions:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;adds a new todo and checks the DOM&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todoapp&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;header.header&#x27;</span>).<span class="title function_">within</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;h1&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">&#x27;todos&#x27;</span>)</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;input.new-todo&#x27;</span>).<span class="title function_">should</span>(</span><br><span class="line">        <span class="string">&#x27;have.attr&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;placeholder&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;What needs to be done?&#x27;</span>,</span><br><span class="line">      )</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;input.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Feed the cat&#123;enter&#125;&#x27;</span>)</span><br><span class="line">    &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>So we have the header inside the <code>class=&quot;todoapp&quot;</code> element. The header contains the <code>h1</code> element with the text &quot;todos&quot; and the input element having some certain attributes. Can we check the structure, attributes, and text easier?</p><p>Sure - by using the custom HTML assertion <code>should look</code> from the <a href="https://github.com/bahmutov/cypress-map">cypress-map</a> plugin.</p><figure class="highlight ts"><figcaption><span>cypress/e2e/todo.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">TodoMVC</span> = &#123;</span><br><span class="line">  <span class="title function_">addTodo</span>(<span class="params">title: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;.new-todo&#x27;</span>).<span class="title function_">type</span>(<span class="string">`<span class="subst">$&#123;title&#125;</span>&#123;enter&#125;`</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">getTodos</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> cy.<span class="title function_">get</span>(<span class="string">&#x27;.todo-list li&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="attr">beTodoItem</span>: <span class="title function_">spok</span>(&#123;</span><br><span class="line">    <span class="attr">title</span>: <span class="string">&#x27;Feed the cat&#x27;</span>,</span><br><span class="line">    <span class="attr">completed</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">id</span>: spok.<span class="title function_">test</span>(</span><br><span class="line">      <span class="regexp">/^[0-9a-f]&#123;8&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;4&#125;-[0-9a-f]&#123;12&#125;$/i</span>,</span><br><span class="line">    ),</span><br><span class="line">  &#125;),</span><br><span class="line"></span><br><span class="line">  <span class="attr">html</span>: <span class="string">`&lt;section class=&quot;todoapp&quot;&gt;</span></span><br><span class="line"><span class="string">    &lt;div&gt;</span></span><br><span class="line"><span class="string">      &lt;header class=&quot;header&quot;&gt;</span></span><br><span class="line"><span class="string">        &lt;h1&gt;todos&lt;/h1&gt;</span></span><br><span class="line"><span class="string">        &lt;input class=&quot;new-todo&quot; placeholder=&quot;What needs to be done?&quot; /&gt;</span></span><br><span class="line"><span class="string">      &lt;/header&gt;</span></span><br><span class="line"><span class="string">    &lt;/div&gt;</span></span><br><span class="line"><span class="string">  &lt;/section&gt;`</span>,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The <code>html</code> property simply lists the &quot;important&quot; elements and their important attributes. It is the small subset of the page, our page must have these HTML nodes. We can use this static string to check the page:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;checks the DOM using page object&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;.todoapp&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;look&#x27;</span>, <span class="title class_">TodoMVC</span>.<span class="property">html</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><img src="../images/check-data-in-page-objects/html.png" alt="Checking the page HTML subset"></p><p>Nice!</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Testers and developers often use page objects to interact with their web applications via DOM elements. Let&amp;#39;s create a page object fo
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Diff Feature Flags Before Running Tests</title>
    <link href="https://glebbahmutov.com/blog/diff-feature-flags-before-testing/"/>
    <id>https://glebbahmutov.com/blog/diff-feature-flags-before-testing/</id>
    <published>2025-09-23T04:00:00.000Z</published>
    <updated>2025-09-24T02:34:07.688Z</updated>
    
    <content type="html"><![CDATA[<p>As I explained in my previous <a href="/blog/test-feature-flags/" title="blog post">blog post</a> on feature flags and testing, you need the flags to be the same to ensure consistent application behavior during end-to-end tests. If you want to test the specific feature, <a href="/blog/cypress-and-launchdarkly/" title="control the flag from the test">control the flag from the test</a>. But sometimes the tests unexpectedly fail when one of the developers or QA engineers is testing something and modifies the flag accidentally (and forgets to turn it back off to the default).</p><p>Here is how you can quickly understand why some tests failed. Imagine we have a LaunchDarkly project with a flag &quot;testing-launch-darkly-control-from-cypress&quot;</p><p><img src="../images/diff-feature-flags-before-testing/flag1.png" alt="Feature flag"></p><p>The flag has several variations</p><p><img src="../images/diff-feature-flags-before-testing/flag2.png" alt="Feature flag variations"></p><p>Currently, our application is receiving the &quot;Formal&quot; greeting, and the tests confirm it</p><h2><span id="store-all-current-feature-flags-as-json">Store all current feature flags as JSON</span></h2><p>In my case, I am using LaunchDarkly and I control it via my <a href="https://github.com/bahmutov/cypress-ld-control">cypress-ld-control</a> plugin. This plugin has a CLI utility for grabbing one or more project flags and printing the JSON to STDIO.</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">LAUNCH_DARKLY_AUTH_TOKEN=... npx list-ld-flags --project demo-project --environment <span class="built_in">test</span></span></span><br></pre></td></tr></table></figure><p>You can save the produced output into a JSON file</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">npx list-ld-flags --project demo-project --environment <span class="built_in">test</span> &gt; ld-flags.json</span></span><br></pre></td></tr></table></figure><figure class="highlight json"><figcaption><span>ld-flags.json</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;demo-project//test&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;key&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testing-launch-darkly-control-from-cypress&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Testing LaunchDarkly Control From Cypress&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Demo feature flag&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;kind&quot;</span><span class="punctuation">:</span> <span class="string">&quot;multivariate&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;deprecated&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;archived&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;temporary&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;defaults&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;offVariation&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;onVariation&quot;</span><span class="punctuation">:</span> <span class="number">0</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;variations&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">        <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;7d910381-5bf1-48c3-9ead-2d99799714ff&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Casual greeting&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Casual&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;value&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Hello&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;762b2ef1-6f38-48c2-92c9-1d73dc39d472&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Formal greeting&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Formal&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;value&quot;</span><span class="punctuation">:</span> <span class="string">&quot;How do you do&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;bd529c6d-4287-4128-aaeb-d4dddc273b6b&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Vacation greeting&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Vacation&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;value&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Aloha&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;environment&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;prerequisites&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;variations&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;0&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">            <span class="attr">&quot;contextTargets&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;isFallthrough&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;nullRules&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;rules&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;targets&quot;</span><span class="punctuation">:</span> <span class="number">0</span></span><br><span class="line">          <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;1&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">            <span class="attr">&quot;contextTargets&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;isOff&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;nullRules&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;rules&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;targets&quot;</span><span class="punctuation">:</span> <span class="number">0</span></span><br><span class="line">          <span class="punctuation">&#125;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>Tip:</strong> you can dump feature flags from multiple projects &#x2F; environments</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">npx list-ld-flags --project project1,project2,project3 --environment e1,e2,e3</span></span><br><span class="line"><span class="meta prompt_"># </span><span class="language-bash">grabs project1 environment e1</span></span><br><span class="line"><span class="meta prompt_"># </span><span class="language-bash">+ project2 environment e2</span></span><br><span class="line"><span class="meta prompt_"># </span><span class="language-bash">+ project3 environment e3</span></span><br></pre></td></tr></table></figure><p>Commit the JSON file and store in your source control, as it should not change too often.</p><h2><span id="diff-flags-before-running-tests">Diff flags before running tests</span></h2><p>Before launching end-to-end tests, output a <em>diff</em> of the current feature flags and the saved flags JSON file. For example, using <a href="/blog/trying-github-actions/" title="GitHub Actions">GitHub Actions</a>:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">LD</span> <span class="string">flag</span> <span class="string">differences</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">node</span> <span class="string">bin/list-ld-flags.js</span> <span class="string">--environment</span> <span class="string">test</span> <span class="string">--diff</span> <span class="string">ld-flags.json</span></span><br><span class="line">  <span class="attr">env:</span></span><br><span class="line">    <span class="comment"># our CI has the project key as an environment variable</span></span><br><span class="line">    <span class="attr">LAUNCH_DARKLY_PROJECT_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_PROJECT_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">LAUNCH_DARKLY_AUTH_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_AUTH_TOKEN</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>Let&#39;s say something changes, maybe someone is testing the new greeting and flipped the LaunchDarkly switch. The CI output shows the change:</p><p><img src="../images/diff-feature-flags-before-testing/ci-diff.png" alt="Feature flag has changed"></p><p>Nice, if any E2E tests fail we will quickly know the suspected reason. The tool also outputs nice GitHub Actions summary with each flag object&#39;s diff:</p><p><img src="../images/diff-feature-flags-before-testing/summary.png" alt="Feature flag difference as GitHub Actions summary"></p><p>Easy to spot any potential feature flags that might affect the tests.</p><h2><span id="update-the-flags-json-when-needed">Update the flags JSON when needed</span></h2><p>If the feature flag defaults change, you might need to update the JSON file. I have a little workflow that the user can run:</p><figure class="highlight yml"><figcaption><span>.github/workflows/ld-flags.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">ld-flags</span></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">workflow_dispatch:</span></span><br><span class="line">    <span class="attr">inputs:</span></span><br><span class="line">      <span class="attr">update-file:</span></span><br><span class="line">        <span class="attr">description:</span> <span class="string">&#x27;Update the ld-flags.json file with the latest flags from LaunchDarkly&#x27;</span></span><br><span class="line">        <span class="attr">required:</span> <span class="literal">false</span></span><br><span class="line">        <span class="attr">default:</span> <span class="literal">false</span></span><br><span class="line">        <span class="attr">type:</span> <span class="string">boolean</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">diff-ld-flags:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="string">//</span> <span class="string">check</span> <span class="string">out</span> <span class="string">code</span> <span class="string">and</span> <span class="string">run</span> <span class="string">the</span> <span class="string">NPM</span> <span class="string">install</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Diff</span> <span class="string">LD</span> <span class="string">flags</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">list-ld-flags</span> <span class="string">--environment</span> <span class="string">test</span> <span class="string">--diff</span> <span class="string">ld-flags.json</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="comment"># our CI has the project key as an environment variable</span></span><br><span class="line">          <span class="attr">LAUNCH_DARKLY_PROJECT_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_PROJECT_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">LAUNCH_DARKLY_AUTH_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_AUTH_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Update</span> <span class="string">ld-flags.json</span></span><br><span class="line">        <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.inputs.update-file</span> <span class="string">==</span> <span class="string">&#x27;true&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">npx</span> <span class="string">list-ld-flags</span> <span class="string">--environment</span> <span class="string">test</span> <span class="string">&gt;</span> <span class="string">ld-flags.json</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">LAUNCH_DARKLY_PROJECT_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_PROJECT_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">LAUNCH_DARKLY_AUTH_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.LAUNCH_DARKLY_AUTH_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Commit</span> <span class="string">updated</span> <span class="string">ld-flags.json</span></span><br><span class="line">        <span class="attr">if:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.inputs.update-file</span> <span class="string">==</span> <span class="string">&#x27;true&#x27;</span> <span class="string">&#125;&#125;</span></span><br><span class="line">        <span class="comment"># https://github.com/planetscale/ghcommit-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">planetscale/ghcommit-action@0.1.41</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">commit_message:</span> <span class="string">Updated</span> <span class="string">ld-flags.json</span> <span class="string">file</span></span><br><span class="line">          <span class="attr">file_pattern:</span> <span class="string">ld-flags.json</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">GITHUB_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.token</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>Any developer can launch the workflow and check the box to commit the changes</p><p><img src="../images/diff-feature-flags-before-testing/workflow.png" alt="Launching the workflow to update the flags file"></p><p>Easy.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;As I explained in my previous &lt;a href=&quot;/blog/test-feature-flags/&quot; title=&quot;blog post&quot;&gt;blog post&lt;/a&gt; on feature flags and testing, you need 
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Tell Copilot Where To Find Test Selectors</title>
    <link href="https://glebbahmutov.com/blog/tell-copilot-where-to-find-test-selectors/"/>
    <id>https://glebbahmutov.com/blog/tell-copilot-where-to-find-test-selectors/</id>
    <published>2025-09-22T04:00:00.000Z</published>
    <updated>2025-09-22T13:38:57.682Z</updated>
    
    <content type="html"><![CDATA[<p>I have shown how to use <a href="/blog/copilot-instructions-and-page-objects/" title="Copilot instructions file">Copilot instructions file</a> to generate better end-to-end Cypress tests. In this blog post I will show a specific trick I use to make my test writing <em>a lot</em> more precise and much faster. When we are writing end-to-end tests, we constantly need to look up the best test selectors to use to find elements on the page. By default, Copilot LLM has <em>no idea</em> about specific format of our page. Copilot simply uses &quot;generic&quot; trained model. For example, I am testing this &quot;Players&quot; page. It should show a hint message explaining to the user what to do.</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/hint1.png" alt="The players page showing the hint"></p><p>As always, I describe what I am trying to achieve and guide AI using inline comments</p><figure class="highlight ts"><figcaption><span>cypress/e2e/players.cy.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Players&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows zero players message&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/player/&#x27;</span>)</span><br><span class="line">    <span class="comment">// you should see the players page</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// the page should give us a hint message</span></span><br><span class="line"></span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>What do you think Copilot would do for the two empty lines?</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/hint2.png" alt="The first Copilot suggestion"></p><p>Copilot model &quot;thinks&quot; that the most likely code completion after the comment &quot;&#x2F;&#x2F; you should see the players page&quot; is <code>cy.contains(&#39;Players&#39;)</code>. This is <em>extremely</em> flaky test selector. It is NOT specific to the page and is likely to pass accidentally. What about the second comment?</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/hint3.png" alt="The second Copilot suggestion"></p><p>Copilot inserts <code>cy.contains(&#39;No players found&#39;)</code> after the comment &quot;&#x2F;&#x2F; the page should give us a hint message&quot;. This is likely to be the effect of the test title &quot;shows zero players message&quot; and not what we are trying to do! It is not what the page has, so the test fails immediately</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/fails.png" alt="The generated test fails"></p><p>In the ideal world we could use <em>the real</em> page DOM markup, as I have shown in the video <a href="https://youtu.be/ClDgJ7radDQ">Add Page HTML To Fix A Cypress Test Error Using Cursor AI</a>. But we can do this in a simpler way. We know that the spec <code>cypress/e2e/players.cy.ts</code> mostly interacts with the page <code>src/routes/player/index.tsx</code> of our application (the end-to-end tests live in the same repo as the app itself). If you look at the source file, can you tell me how the elements in question should be selected?</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/page.png" alt="the source page markup"></p><p>I haven&#39;t even told you that this app uses <a href="https://qwik.dev/">Qwik</a> framework - since it does not matter and should not affect your end-to-end tests. Yet, you see the obvious candidates for test selectors for our test. We will use the <code>data-cy</code> attributes which are stable and are meant for testing.</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/candidates.png" alt="data-cy attributes make for good stable test selectors"></p><p><strong>Tip:</strong> if we keep the <code>src/routes/player/index.tsx</code> file open in one of our VSCode tabs, Copilot should be able to use it to find test selectors. But we want to make sure <em>everyone</em> can find the selectors, even when they are unaware of the project&#39;s structured.</p><p>This is where the Copilot instruction file comes in. We simply can &quot;tell&quot; Copilot the main source files to use when working with specific spec files.</p><figure class="highlight md"><figcaption><span>.github/copilot-instructions.md</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">## Use Cypress syntax</span></span><br><span class="line"></span><br><span class="line">This repo uses Cypress to implement end-to-end tests.</span><br><span class="line"></span><br><span class="line">...</span><br><span class="line"></span><br><span class="line"><span class="section">## Spec file and selectors</span></span><br><span class="line"></span><br><span class="line">When editing the following spec files, look up the relevant element selectors, markup,</span><br><span class="line">and values from the given files:</span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> cypress/e2e/history.cy.js - src/routes/history/index.tsx</span><br><span class="line"><span class="bullet">-</span> cypress/e2e/history-games.cy.ts - src/routes/history/game/[id]/index.tsx</span><br><span class="line"><span class="bullet">-</span> cypress/e2e/finish-page.cy.ts - src/routes/finish/index.tsx and src/components/finished-game.tsx</span><br><span class="line"><span class="bullet">-</span> cypress/e2e/finish-the-game.cy.ts - src/routes/finish/index.tsx</span><br><span class="line"><span class="bullet">-</span> cypress/e2e/players.cy.ts - src/routes/player/index.tsx</span><br><span class="line"><span class="bullet">-</span> cypress/e2e/carousel.cy.ts - src/components/screenshots.tsx</span><br></pre></td></tr></table></figure><p>Let&#39;s see what Copilot suggests now</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/hints.png" alt="Copilot picks data-cy test selectors"></p><p><strong>Note:</strong> you might need to enable Copilot instructions file in your VSCode Copilot settings.</p><p><img src="../images/tell-copilot-where-to-find-test-selectors/settings.png" alt="VSCode Copilot setting to enable the instructions file"></p><p>See how to write Copilot instructions file in the <a href="https://code.visualstudio.com/docs/copilot/customization/custom-instructions">official docs</a></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I have shown how to use &lt;a href=&quot;/blog/copilot-instructions-and-page-objects/&quot; title=&quot;Copilot instructions file&quot;&gt;Copilot instructions fil
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="copilot" scheme="https://glebbahmutov.com/blog/tags/copilot/"/>
    
  </entry>
  
  <entry>
    <title>Add Types To Your Local Storage During End-to-End Tests</title>
    <link href="https://glebbahmutov.com/blog/type-local-storage-test-commands/"/>
    <id>https://glebbahmutov.com/blog/type-local-storage-test-commands/</id>
    <published>2025-09-16T04:00:00.000Z</published>
    <updated>2025-09-16T14:31:01.500Z</updated>
    
    <content type="html"><![CDATA[<p>If your web application stores data locally in the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">localStorage</a> object, you can easily set &#x2F; verify the data from your Cypress end-to-end tests. For example:</p><figure class="highlight js"><figcaption><span>cypress/e2e/players.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">https</span>:<span class="comment">//github.com/bahmutov/cy-spok</span></span><br><span class="line"><span class="keyword">import</span> spok <span class="keyword">from</span> <span class="string">&#x27;cy-spok&#x27;</span></span><br><span class="line"><span class="comment">// https://github.com/bahmutov/cypress-map</span></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-map&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;stores new players in local storage&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/player/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=firstName]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.focused&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Joe&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=lastName]&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Smith&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;Add player&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;li.player&#x27;</span>)</span><br><span class="line">    .<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">1</span>)</span><br><span class="line">    .<span class="title function_">contains</span>(<span class="string">&#x27;li&#x27;</span>, <span class="string">&#x27;Joe Smith&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=firstName]&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Gus&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=lastName]&#x27;</span>).<span class="title function_">type</span>(<span class="string">&#x27;Smalls&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;Add player&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;li.player&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">2</span>)</span><br><span class="line"></span><br><span class="line">  cy.<span class="title function_">window</span>()</span><br><span class="line">    .<span class="title function_">its</span>(<span class="string">&#x27;localStorage&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invoke</span>(<span class="string">&#x27;getItem&#x27;</span>, <span class="string">&#x27;players&#x27;</span>)</span><br><span class="line">    .<span class="title function_">apply</span>(<span class="title class_">JSON</span>.<span class="property">parse</span>)</span><br><span class="line">    .<span class="title function_">should</span>(</span><br><span class="line">      <span class="title function_">spok</span>([</span><br><span class="line">        &#123;</span><br><span class="line">          <span class="attr">id</span>: spok.<span class="property">string</span>,</span><br><span class="line">          <span class="attr">firstName</span>: <span class="string">&#x27;Joe&#x27;</span>,</span><br><span class="line">          <span class="attr">lastName</span>: <span class="string">&#x27;Smith&#x27;</span>,</span><br><span class="line">        &#125;,</span><br><span class="line">        &#123;</span><br><span class="line">          <span class="attr">id</span>: spok.<span class="property">string</span>,</span><br><span class="line">          <span class="attr">firstName</span>: <span class="string">&#x27;Gus&#x27;</span>,</span><br><span class="line">          <span class="attr">lastName</span>: <span class="string">&#x27;Smalls&#x27;</span>,</span><br><span class="line">        &#125;,</span><br><span class="line">      ]),</span><br><span class="line">    )</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The test above uses the page to add new soccer players and then verifies the local storage has the expected &quot;shape&quot; and data.</p><p><img src="../images/type-local-storage-test-commands/players.png" alt="The players test"></p><blockquote class="pullquote"><p>📦 The code examples in this blog post come from my private &quot;Gametime&quot; repo which has a web app for tracking the soccer games. I wrote this app to help me coach the Cambridge youth soccer teams. I am thinking how to better open source this application, but everyone can use the app to track time and game results: <a href="https://glebbahmutov.com/gametime/">glebbahmutov.com&#x2F;gametime&#x2F;</a></p></blockquote><p>We can also set the local storage before loading the page; this makes the tests run much faster, since they don&#39;t have to recreate the state via UI.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;loads players&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> players = [</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">id</span>: spok.<span class="property">string</span>,</span><br><span class="line">      <span class="attr">firstName</span>: <span class="string">&#x27;Joe&#x27;</span>,</span><br><span class="line">      <span class="attr">lastName</span>: <span class="string">&#x27;Smith&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">id</span>: spok.<span class="property">string</span>,</span><br><span class="line">      <span class="attr">firstName</span>: <span class="string">&#x27;Gus&#x27;</span>,</span><br><span class="line">      <span class="attr">lastName</span>: <span class="string">&#x27;Smalls&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">  ]</span><br><span class="line">  <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;players&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(players))</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/player/&#x27;</span>)</span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#players li.player&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.length&#x27;</span>, <span class="number">2</span>)</span><br><span class="line">  players.<span class="title function_">forEach</span>(<span class="function">(<span class="params">player, k</span>) =&gt;</span> &#123;</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;#players li.player&#x27;</span>)</span><br><span class="line">      .<span class="title function_">eq</span>(k)</span><br><span class="line">      .<span class="title function_">find</span>(<span class="string">&#x27;.name&#x27;</span>)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">`<span class="subst">$&#123;player.firstName&#125;</span> <span class="subst">$&#123;player.lastName&#125;</span>`</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The test confirms that the page can read the players from the local storage on page visit and shows each player&#39;s name.</p><p><img src="../images/type-local-storage-test-commands/load.png" alt="The page shows loaded players"></p><h2><span id="problems">Problems</span></h2><p>The above test has a few issues:</p><ol><li>the <code>window.localStorage.setItem</code> method is synchronous and runs <em>before</em> any Cypress command executes. This can lead to the unexpected results. For example, we could test the page reload and it would NOT work as written</li></ol><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 🚨 INCORRECT, THIS TEST WILL NOT AS EXPECTED</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// two different lists of players</span></span><br><span class="line"><span class="keyword">const</span> players1 = [...]</span><br><span class="line"><span class="keyword">const</span> players2 = [...]</span><br><span class="line"><span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;players&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(players1))</span><br><span class="line">cy.<span class="title function_">visit</span>(<span class="string">&#x27;/player/&#x27;</span>)</span><br><span class="line"><span class="comment">// confirm we see players from the list &quot;players1&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;players&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(players2))</span><br><span class="line">cy.<span class="title function_">reload</span>()</span><br><span class="line"><span class="comment">// confirm we see the players from the list &quot;players2&quot;</span></span><br></pre></td></tr></table></figure><p>Do you think this test works as intended? No. It will fail checking the players from the list &quot;players1&quot;. For some reason, after the <code>cy.visit</code> command finishes you see the players ... from list 2! This is because the two <code>localStorage.setItem</code> commands execute immediately, while <code>cy</code> commands are <a href="/blog/visualize-cypress-command-queue/" title="queued up">queued up</a>. So when the <code>cy.visit</code> command runs, the local storage already has the <code>players2</code> list set.</p><ol start="2"><li>Another problem is having pieces of JSON objects across multiple tests and specs. Many tests in this web app have players, teams, and game in progress pieces of state set before we visit the page</li></ol><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;players&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(players))</span><br><span class="line"><span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;teams&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(teams))</span><br><span class="line"><span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(</span><br><span class="line">  <span class="string">&#x27;prepare-game&#x27;</span>,</span><br><span class="line">  <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(preparedGame),</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>Some objects are simple, like a single player. Some are more complex and changing. For example, the &quot;prepared game&quot; becomes a game in progress with lots of fields:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="attr">initialGameInProgress</span>: <span class="title class_">GameInProgress</span> = &#123;</span><br><span class="line">  teamId,</span><br><span class="line">  <span class="attr">teamName</span>: <span class="string">&#x27;&#x27;</span>,</span><br><span class="line">  <span class="attr">fieldPlayerIds</span>: [] <span class="keyword">as</span> <span class="built_in">string</span>[],</span><br><span class="line">  <span class="attr">subPlayerIds</span>: [] <span class="keyword">as</span> <span class="built_in">string</span>[],</span><br><span class="line">  <span class="attr">otherPlayerIds</span>: [] <span class="keyword">as</span> <span class="built_in">string</span>[],</span><br><span class="line">  <span class="attr">gameIsRunning</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="comment">// game start, ms since epoch</span></span><br><span class="line">  <span class="attr">gameStartedAt</span>: <span class="number">0</span>,</span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">    * Time duration since the start of the game, ms</span></span><br><span class="line"><span class="comment">    */</span></span><br><span class="line">  <span class="attr">gameClockMs</span>: <span class="number">0</span>,</span><br><span class="line">  <span class="attr">gameClockText</span>: <span class="string">&#x27;00:00&#x27;</span>,</span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">    * for each player id, the total time they played, ms</span></span><br><span class="line"><span class="comment">    */</span></span><br><span class="line">  <span class="attr">playersGameTime</span>: &#123;&#125; <span class="keyword">as</span> <span class="title class_">Record</span>&lt;<span class="built_in">string</span>, <span class="built_in">number</span>&gt;,</span><br><span class="line">  <span class="attr">subsToDo</span>: [] <span class="keyword">as</span> <span class="title class_">SubToDo</span>[],</span><br><span class="line">  <span class="attr">gameFinished</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">ourTeamGoals</span>: <span class="number">0</span>,</span><br><span class="line">  <span class="attr">opponentTeamGoals</span>: <span class="number">0</span>,</span><br><span class="line">  <span class="comment">/** notes about individual goals */</span></span><br><span class="line">  <span class="attr">notes</span>: [] <span class="keyword">as</span> <span class="title class_">GameNote</span>[],</span><br><span class="line">  <span class="attr">opponentName</span>: <span class="string">&#x27;them&#x27;</span>,</span><br><span class="line">  <span class="comment">/** current field player positions, like &quot;Goalkeeper&quot; or &quot;Center striker&quot; */</span></span><br><span class="line">  <span class="attr">playerPositions</span>: &#123;&#125; <span class="keyword">as</span> <span class="title class_">Record</span>&lt;<span class="built_in">string</span>, <span class="built_in">string</span>&gt;,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>If the type <code>GameInProgress</code> changes, we would need to check all tests that use <code>window.localStorage.setItem(&#39;prepare-game&#39;, ...)</code> to ensure the value is a valid object.</p><h2><span id="solution">Solution</span></h2><p>My preferred solution to both problems is <a href="/blog/writing-custom-cypress-command/" title="writing custom Cypress commands">writing custom Cypress commands</a> with explicit typed interface. Here is how it looks in practice. First, we will provide several simple Cypress commands wrapping <code>localStorage</code> access.</p><figure class="highlight ts"><figcaption><span>cypress/support/commands.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress&quot; /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="title class_">Cypress</span>.<span class="property">Commands</span>.<span class="title function_">add</span>(</span><br><span class="line">  <span class="string">&#x27;setLocalStorage&#x27;</span>,</span><br><span class="line">  <span class="function">(<span class="params">key: <span class="built_in">string</span>, value: <span class="built_in">unknown</span></span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(key, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(value))</span><br><span class="line">  &#125;,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="title class_">Cypress</span>.<span class="property">Commands</span>.<span class="title function_">add</span>(<span class="string">&#x27;removeLocalStorage&#x27;</span>, <span class="function">(<span class="params">key: <span class="built_in">string</span></span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">removeItem</span>(key)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="title class_">Cypress</span>.<span class="property">Commands</span>.<span class="title function_">addQuery</span>(<span class="string">&#x27;getLocalStorage&#x27;</span>, <span class="function">(<span class="params">key: <span class="built_in">string</span></span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> item = <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">getItem</span>(key)</span><br><span class="line">    <span class="keyword">return</span> item ? <span class="title class_">JSON</span>.<span class="title function_">parse</span>(item) : <span class="literal">null</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>The commands file is included from the Cypress support file together with plugins</p><figure class="highlight ts"><figcaption><span>cypress/support/e2e.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-map&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-plugin-steps&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="string">&#x27;./commands&#x27;</span></span><br></pre></td></tr></table></figure><p>Using the custom Cypress commands automatically ensures the data is in the right order between other Cypress commands.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// ✅ THE CORRECT TEST</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// two different lists of players</span></span><br><span class="line"><span class="keyword">const</span> players1 = [...]</span><br><span class="line"><span class="keyword">const</span> players2 = [...]</span><br><span class="line">cy.<span class="title function_">setLocalStorage</span>(<span class="string">&#x27;players&#x27;</span>, players1)</span><br><span class="line">cy.<span class="title function_">visit</span>(<span class="string">&#x27;/player/&#x27;</span>)</span><br><span class="line"><span class="comment">// confirm we see players from the list &quot;players1&quot;</span></span><br><span class="line"></span><br><span class="line">cy.<span class="title function_">setLocalStorage</span>(<span class="string">&#x27;players&#x27;</span>, players2)</span><br><span class="line">cy.<span class="title function_">reload</span>()</span><br><span class="line"><span class="comment">// confirm we see the players from the list &quot;players2&quot;</span></span><br></pre></td></tr></table></figure><p>In the test above, the second list <code>players2</code> will be written into the local storage <em>after</em> all previous Cypress commands have finished.</p><p>Second, we describe the allowed types for these commands</p><figure class="highlight ts"><figcaption><span>cypress/support/index.d.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// load types that include Cypress and included plugins</span></span><br><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress-map&quot; /&gt;</span></span><br><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress&quot; /&gt;</span></span><br><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress-plugin-steps&quot; /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="keyword">type</span> &#123;</span><br><span class="line">  <span class="title class_">Player</span>,</span><br><span class="line">  <span class="title class_">Team</span>,</span><br><span class="line">  <span class="title class_">PreparedGame</span>,</span><br><span class="line">  <span class="title class_">HistoricGame</span>,</span><br><span class="line">  <span class="title class_">GameInProgress</span>,</span><br><span class="line">&#125; <span class="keyword">from</span> <span class="string">&#x27;../../src/model&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">declare</span> <span class="variable language_">global</span> &#123;</span><br><span class="line">  <span class="keyword">namespace</span> <span class="title class_">Cypress</span> &#123;</span><br><span class="line">    <span class="keyword">interface</span> <span class="title class_">Chainable</span> &#123;</span><br><span class="line">      <span class="comment">//</span></span><br><span class="line">      <span class="comment">// provide custom type overrides</span></span><br><span class="line">      <span class="comment">// for writing specific local storage items</span></span><br><span class="line">      <span class="comment">//</span></span><br><span class="line"></span><br><span class="line">      <span class="comment">// set items</span></span><br><span class="line">      <span class="title function_">setLocalStorage</span>(</span><br><span class="line">        <span class="attr">key</span>: <span class="string">&#x27;players&#x27;</span>,</span><br><span class="line">        <span class="attr">value</span>: <span class="title class_">Player</span>[],</span><br><span class="line">      ): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br><span class="line"></span><br><span class="line">      <span class="title function_">setLocalStorage</span>(<span class="attr">key</span>: <span class="string">&#x27;teams&#x27;</span>, <span class="attr">value</span>: <span class="title class_">Team</span>[]): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br><span class="line"></span><br><span class="line">      <span class="title function_">setLocalStorage</span>(</span><br><span class="line">        <span class="attr">key</span>: <span class="string">&#x27;prepare-game&#x27;</span>,</span><br><span class="line">        <span class="attr">value</span>: <span class="title class_">PreparedGame</span> | <span class="title class_">GameInProgress</span>,</span><br><span class="line">      ): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br><span class="line"></span><br><span class="line">      <span class="title function_">setLocalStorage</span>(</span><br><span class="line">        <span class="attr">key</span>: <span class="string">&#x27;history&#x27;</span>,</span><br><span class="line">        <span class="attr">value</span>: <span class="title class_">HistoricGame</span>[],</span><br><span class="line">      ): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br><span class="line"></span><br><span class="line">      <span class="comment">// get items</span></span><br><span class="line">      <span class="title function_">getLocalStorage</span>(<span class="attr">key</span>: <span class="string">&#x27;players&#x27;</span>): <span class="title class_">Chainable</span>&lt;<span class="title class_">Player</span>[] | <span class="literal">null</span>&gt;</span><br><span class="line">      <span class="title function_">getLocalStorage</span>(<span class="attr">key</span>: <span class="string">&#x27;teams&#x27;</span>): <span class="title class_">Chainable</span>&lt;<span class="title class_">Team</span>[] | <span class="literal">null</span>&gt;</span><br><span class="line">      <span class="title function_">getLocalStorage</span>(</span><br><span class="line">        <span class="attr">key</span>: <span class="string">&#x27;prepare-game&#x27;</span>,</span><br><span class="line">      ): <span class="title class_">Chainable</span>&lt;<span class="title class_">GameInProgress</span> | <span class="literal">null</span>&gt;</span><br><span class="line"></span><br><span class="line">      <span class="comment">// remove items</span></span><br><span class="line">      <span class="title function_">removeLocalStorage</span>(</span><br><span class="line">        <span class="attr">key</span>: <span class="string">&#x27;players&#x27;</span> | <span class="string">&#x27;teams&#x27;</span> | <span class="string">&#x27;prepare-game&#x27;</span> | <span class="string">&#x27;history&#x27;</span>,</span><br><span class="line">      ): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> &#123;&#125;</span><br></pre></td></tr></table></figure><p>Because my end-to-end tests reside in the same repo with the rest of the application code, I can reuse the &quot;official&quot; types and even <a href="/blog/using-ts-aliases-in-cypress-tests/" title="TypeScript aliases">TypeScript aliases</a>.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="keyword">type</span> &#123;</span><br><span class="line">  <span class="title class_">Player</span>,</span><br><span class="line">  <span class="title class_">Team</span>,</span><br><span class="line">  <span class="title class_">PreparedGame</span>,</span><br><span class="line">  <span class="title class_">HistoricGame</span>,</span><br><span class="line">  <span class="title class_">GameInProgress</span>,</span><br><span class="line">&#125; <span class="keyword">from</span> <span class="string">&#x27;~/model&#x27;</span></span><br></pre></td></tr></table></figure><p>Notice how I provide only <em>specific</em> method signatures. For example:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">setLocalStorage</span>(<span class="attr">key</span>: <span class="string">&#x27;teams&#x27;</span>, <span class="attr">value</span>: <span class="title class_">Team</span>[]): <span class="title class_">Chainable</span>&lt;<span class="built_in">void</span>&gt;</span><br></pre></td></tr></table></figure><p>If someone passes an object that does not satisfy the <code>Team</code> interface when using <code>cy.setLocalStorage(&#39;teams&#39;, ...)</code>, TypeScript compiler will complain. For example, if the team object I am trying to set in the local storage is missing the <code>id</code> property:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> teams = [</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">teamName</span>: <span class="string">&#x27;Team A&#x27;</span>,</span><br><span class="line">    <span class="comment">// id: &#x27;team1&#x27;,</span></span><br><span class="line">    <span class="attr">playerIds</span>: [<span class="string">&#x27;101&#x27;</span>, <span class="string">&#x27;102&#x27;</span>],</span><br><span class="line">  &#125;,</span><br><span class="line">]</span><br><span class="line">cy.<span class="title function_">setLocalStorage</span>(<span class="string">&#x27;teams&#x27;</span>, teams)</span><br></pre></td></tr></table></figure><p>TypeScript check catches this:</p><p><img src="../images/type-local-storage-test-commands/types.png" alt="TypeScript catches invalid JSON object the test tries to set"></p><p>Great, the test data is guaranteed to at least have the right shape.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;If your web application stores data locally in the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage&quot;&gt;localSt
      
    
    </summary>
    
      <category term="process" scheme="https://glebbahmutov.com/blog/categories/process/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
      <category term="typescript" scheme="https://glebbahmutov.com/blog/tags/typescript/"/>
    
  </entry>
  
  <entry>
    <title>AI Picks Tests To Run On A Bug</title>
    <link href="https://glebbahmutov.com/blog/ai-picks-tests-to-run-on-a-bug/"/>
    <id>https://glebbahmutov.com/blog/ai-picks-tests-to-run-on-a-bug/</id>
    <published>2025-09-09T04:00:00.000Z</published>
    <updated>2025-09-09T03:03:53.655Z</updated>
    
    <content type="html"><![CDATA[<p>In the blog post <a href="/blog/test-tag-suggestions-using-ai/" title="Test Tag Suggestions Using AI">Test Tag Suggestions Using AI</a> I described a system to pick a testing tag based on a pull request&#39;s title and body text. In this blog post, I will make it useful. Whenever a user opens a GitHub issue and labels it a &quot;bug&quot;, an automated workflow will pick an appropriate testing tag (or several) and will execute the tagged Cypress end-to-end tests to give more context to the issue.</p><h2><span id="the-example-application">The example application</span></h2><p>I am using a typical TodoMVC with lots of Cypress end-to-end tests tagged using <a href="https://github.com/bahmutov/cy-grep">@bahmutov&#x2F;cy-grep</a> plugin. You can list all specs with their tags using <a href="https://github.com/bahmutov/find-cypress-specs">find-cypress-specs</a> utility.</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br></pre></td><td class="code"><pre><span class="line">$ npx find-cypress-specs --names</span><br><span class="line"></span><br><span class="line">cypress/e2e/app-spec.js (15 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  ├─ adds 4 todos [@smoke, @add]</span><br><span class="line">  ├─ When page is initially opened</span><br><span class="line">  │ └─ should focus on the todo input field</span><br><span class="line">  ├─ No Todos</span><br><span class="line">  │ └─ should hide <span class="comment">#main and #footer [@misc]</span></span><br><span class="line">  ├─ New Todo [@add]</span><br><span class="line">  │ ├─ should allow me to add todo items</span><br><span class="line">  │ ├─ adds items</span><br><span class="line">  │ ├─ should clear text input field when an item is added</span><br><span class="line">  │ ├─ should append new items to the bottom of the list</span><br><span class="line">  │ ├─ should trim text input</span><br><span class="line">  │ └─ should show <span class="comment">#main and #footer when items added</span></span><br><span class="line">  ├─ Item</span><br><span class="line">  │ ├─ should allow me to mark items as complete</span><br><span class="line">  │ ├─ should allow me to un-mark items as complete</span><br><span class="line">  │ └─ should allow me to edit an item</span><br><span class="line">  └─ Clear completed button</span><br><span class="line">    ├─ should display the correct text</span><br><span class="line">    ├─ should remove completed items when clicked [@smoke]</span><br><span class="line">    └─ should be hidden when there are no items that are completed</span><br><span class="line"></span><br><span class="line">cypress/e2e/completed-spec.js (3 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  └─ Mark all as completed [@complete]</span><br><span class="line">    ├─ should allow me to mark all items as completed</span><br><span class="line">    ├─ should allow me to clear the complete state of all items</span><br><span class="line">    └─ complete all checkbox should update state when items are completed / cleared</span><br><span class="line"></span><br><span class="line">cypress/e2e/counter-spec.js (2 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  └─ Counter [@count]</span><br><span class="line">    ├─ should not exist without items</span><br><span class="line">    └─ should display the current number of todo items</span><br><span class="line"></span><br><span class="line">cypress/e2e/editing-spec.js (5 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  └─ Editing [@edit]</span><br><span class="line">    ├─ should hide other controls when editing</span><br><span class="line">    ├─ should save edits on blur [@smoke]</span><br><span class="line">    ├─ should trim entered text</span><br><span class="line">    ├─ should remove the item <span class="keyword">if</span> an empty text string was entered</span><br><span class="line">    └─ should cancel edits on escape</span><br><span class="line"></span><br><span class="line">cypress/e2e/persistence-spec.js (1 <span class="built_in">test</span>)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  └─ Persistence [@persistence]</span><br><span class="line">    └─ should persist its data [@smoke]</span><br><span class="line"></span><br><span class="line">cypress/e2e/routing-spec.js (5 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  └─ Routing [@routing]</span><br><span class="line">    ├─ should allow me to display active items</span><br><span class="line">    ├─ should respect the back button [@smoke]</span><br><span class="line">    ├─ should allow me to display completed items</span><br><span class="line">    ├─ should allow me to display all items @smoke</span><br><span class="line">    └─ should highlight the currently applied filter</span><br><span class="line"></span><br><span class="line">found 6 specs (31 tests)</span><br></pre></td></tr></table></figure><p>We have a few feature testing tags. Let&#39;s count the number of tests by tag</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">$ npx find-cypress-specs --tags</span><br><span class="line"></span><br><span class="line">Tag           Tests</span><br><span class="line">------------  -----</span><br><span class="line">@add          7</span><br><span class="line">@complete     3</span><br><span class="line">@count        2</span><br><span class="line">@edit         5</span><br><span class="line">@misc         1</span><br><span class="line">@persistence  1</span><br><span class="line">@routing      5</span><br><span class="line">@smoke        5</span><br></pre></td></tr></table></figure><p>The <code>@smoke</code> tag cuts across all features, following the approach described in <a href="/blog/tag-tests/" title="How To Tag And Run End-to-End Tests">How To Tag And Run End-to-End Tests</a>.</p><h2><span id="a-problem">A problem</span></h2><p>Let&#39;s say our application has a bug, somehow we introduced a problem into the &quot;toggle all&quot; function logic. No one caught the problem during the code review, and no one bothered to run the end-to-end tests (🙀)</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">  app.TodoModel.prototype.toggleAll = function (checked) &#123;</span><br><span class="line">    // Note: it&#x27;s usually better to use immutable data structures since they&#x27;re</span><br><span class="line">    // easier to reason about and React works very well with them. That&#x27;s why</span><br><span class="line">    // we use map() and filter() everywhere instead of mutating the array or</span><br><span class="line">    // todo items themselves.</span><br><span class="line">    this.todos = this.todos.map(function (todo) &#123;</span><br><span class="line"><span class="deletion">-     return Utils.extend(&#123;&#125;, todo, &#123; completed: checked &#125;)</span></span><br><span class="line"><span class="addition">+     // introduce an error on purpose by negating the checked value</span></span><br><span class="line"><span class="addition">+     return Utils.extend(&#123;&#125;, todo, &#123; completed: !checked &#125;)</span></span><br><span class="line">    &#125;)</span><br><span class="line"></span><br><span class="line">    this.inform()</span><br><span class="line">  &#125;</span><br></pre></td></tr></table></figure><p>Hmm, we have deployed the app with a bug, and soon a user opens a GitHub issue. Knowing the typical user, the level of detail in the GH issue is minimal.</p><p><img src="../images/ai-picks-tests/issue.png" alt="A user opens an issue"></p><p>Great. The issue has the title &quot;toggle does not work&quot;, an empty body text, and has the label &quot;bug&quot;.</p><h2><span id="the-bug-workflow">The bug workflow</span></h2><p>Opening or re-opening an issue labeled &quot;bug&quot; triggers the following GitHub Actions workflow</p><figure class="highlight yml"><figcaption><span>.github/workflows/bug.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># this workflow runs when a user opens a new issue labeled &quot;bug&quot;</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">bug</span></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">issues:</span></span><br><span class="line">    <span class="attr">types:</span> [<span class="string">opened</span>, <span class="string">reopened</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">pick-test-tag:</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">contains(github.event.issue.labels.*.name,</span> <span class="string">&#x27;bug&#x27;</span><span class="string">)</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">outputs:</span></span><br><span class="line">      <span class="attr">testTag:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">inputTokens:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.inputTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">outputTokens:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.outputTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">totalTokens:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.totalTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="attr">model:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.model</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Pick</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">find</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">bahmutov/run-tests-on-a-bug/.github/actions/pick-test-tag@main</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">title:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.issue.title</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">body:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.issue.body</span> <span class="string">&#125;&#125;</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">OPEN_AI_API_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_API_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">run-picked-tests:</span></span><br><span class="line">    <span class="attr">if:</span> <span class="string">contains(github.event.issue.labels.*.name,</span> <span class="string">&#x27;bug&#x27;</span><span class="string">)</span></span><br><span class="line">    <span class="attr">needs:</span> <span class="string">pick-test-tag</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">permissions:</span></span><br><span class="line">      <span class="comment"># this job needs to check out the source code</span></span><br><span class="line">      <span class="attr">contents:</span> <span class="string">read</span></span><br><span class="line">      <span class="comment"># give this job permission to comment on the issue</span></span><br><span class="line">      <span class="attr">issues:</span> <span class="string">write</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">issue</span> <span class="string">title</span> <span class="string">and</span> <span class="string">subject</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;Issue title:&quot;</span></span><br><span class="line"><span class="string">          echo &quot;$&#123;&#123; github.event.issue.title &#125;&#125;&quot;</span></span><br><span class="line"><span class="string">          echo &quot;Issue body:&quot;</span></span><br><span class="line"><span class="string">          echo &quot;$&#123;&#123; github.event.issue.body &#125;&#125;&quot;</span></span><br><span class="line"><span class="string">          echo &quot;Picked test tag(s)&quot;</span></span><br><span class="line"><span class="string">          echo &quot;$&#123;&#123; needs.pick-test-tag.outputs.testTag &#125;&#125;&quot;</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Comment</span> <span class="string">on</span> <span class="string">the</span> <span class="string">issue</span> <span class="string">📝</span></span><br><span class="line">        <span class="comment"># https://github.com/peter-evans/create-or-update-comment</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">peter-evans/create-or-update-comment@v4</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">comment</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">issue-number:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.issue.number</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">body:</span> <span class="string">|</span></span><br><span class="line"><span class="string">            Thanks for reporting this issue! We will look into it as soon as we can.</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">            <span class="string">In</span> <span class="string">the</span> <span class="string">meantime,</span> <span class="string">we</span> <span class="string">are</span> <span class="string">running</span> <span class="string">tests</span> <span class="string">tagged</span> <span class="string">with</span> <span class="string">`$&#123;&#123;</span> <span class="string">needs.pick-test-tag.outputs.testTag</span> <span class="string">&#125;&#125;`</span> <span class="string">to</span> <span class="string">see</span> <span class="string">if</span> <span class="string">anything</span> <span class="string">is</span> <span class="string">broken.</span></span><br><span class="line">            <span class="attr">The GitHub Actions run url is here:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.server_url</span> <span class="string">&#125;&#125;/$&#123;&#123;</span> <span class="string">github.repository</span> <span class="string">&#125;&#125;/actions/runs/$&#123;&#123;</span> <span class="string">github.run_id</span> <span class="string">&#125;&#125;.</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v5</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">tagged</span> <span class="string">tests</span> <span class="string">🧪</span></span><br><span class="line">        <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="comment"># let&#x27;s see which specs and tests we will run</span></span><br><span class="line">          <span class="attr">build:</span> <span class="string">npx</span> <span class="string">find-cypress-specs</span> <span class="string">--names</span> <span class="string">--tagged</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.pick-test-tag.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">start:</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">start:ci</span></span><br><span class="line">          <span class="attr">wait-on:</span> <span class="string">&#x27;http://localhost:8888&#x27;</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">CYPRESS_grepTags:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.pick-test-tag.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="comment"># put test results into the comment</span></span><br><span class="line">          <span class="comment"># https://github.com/bahmutov/cypress-set-github-status</span></span><br><span class="line">          <span class="attr">GITHUB_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">COMMENT_ID:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.comment.outputs.comment-id</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line">      <span class="comment"># after the test run completes store videos and any screenshots</span></span><br><span class="line">      <span class="comment"># https://github.com/actions/upload-artifact</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/upload-artifact@v4</span></span><br><span class="line">        <span class="attr">if:</span> <span class="string">failure()</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">name:</span> <span class="string">cypress-screenshots</span></span><br><span class="line">          <span class="attr">path:</span> <span class="string">cypress/screenshots</span></span><br><span class="line">          <span class="attr">if-no-files-found:</span> <span class="string">ignore</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/upload-artifact@v4</span></span><br><span class="line">        <span class="attr">if:</span> <span class="string">always()</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">name:</span> <span class="string">cypress-videos</span></span><br><span class="line">          <span class="attr">path:</span> <span class="string">cypress/videos</span></span><br><span class="line">          <span class="attr">if-no-files-found:</span> <span class="string">ignore</span></span><br></pre></td></tr></table></figure><blockquote class="pullquote"><p>Currently, the example application repo is private. I am thinking how to better open source this work.</p></blockquote><p>The workflow runs only for issues labeled a &quot;bug&quot;:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">issues:</span></span><br><span class="line">    <span class="attr">types:</span> [<span class="string">opened</span>, <span class="string">reopened</span>]</span><br></pre></td></tr></table></figure><p>A response comment appears quickly</p><p><img src="../images/ai-picks-tests/first-comment.png" alt="The bug workflow posts the response comment"></p><p>There are two jobs in the workflow: &quot;pick-test-tag&quot; followed by &quot;run-picked-tests&quot;</p><h2><span id="picking-the-testing-tags">Picking the testing tags</span></h2><p>Based on the user&#39;s description of the bug (title and body), we want to know if any of the tested features related to the user&#39;s report are broken. Because there might be more than a single broken page or user action, we might have a seriously broken app! We want to test everything related to the bug report, and hopefully the test recordings and logs will help us quickly isolate the problem and fix the issue.</p><p>To pick the testing tag based on the user&#39;s text, I use the following AI script</p><figure class="highlight js"><figcaption><span>.github/actions/pick-test-tag/pick.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// @ts-check</span></span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * These are valid test tags used in our test cases,</span></span><br><span class="line"><span class="comment"> * plus their descriptions</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">const</span> <span class="variable constant_">TEST_TAGS</span> = &#123;</span><br><span class="line">  <span class="string">&#x27;@smoke&#x27;</span>: <span class="string">&#x27;Smoke tests - a small set of tests to check the main features&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@misc&#x27;</span>: <span class="string">&#x27;Miscellaneous unimportant tests&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@add&#x27;</span>: <span class="string">&#x27;Tests related to adding new todo items to the list&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@edit&#x27;</span>: <span class="string">&#x27;Tests related to editing existing todo items in the list&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@routing&#x27;</span>:</span><br><span class="line">    <span class="string">&#x27;Tests related to routing between different views and pages in the app&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@complete&#x27;</span>: <span class="string">&#x27;Tests related to completing tasks and checking/unchecking&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@count&#x27;</span>: <span class="string">&#x27;Tests confirming the count of items on the page is correct&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;@persistence&#x27;</span>:</span><br><span class="line">    <span class="string">&#x27;Tests related to data persistence: saving and loading items in storage&#x27;</span>,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">ask</span>(<span class="params">instructions, input, core, client</span>) &#123;</span><br><span class="line">  <span class="comment">// https://platform.openai.com/docs/models</span></span><br><span class="line">  <span class="comment">// usually gpt-4.1-mini or gpt-4.1</span></span><br><span class="line">  <span class="keyword">const</span> model = <span class="string">&#x27;gpt-4.1&#x27;</span></span><br><span class="line">  <span class="keyword">const</span> response = <span class="keyword">await</span> client.<span class="property">responses</span>.<span class="title function_">create</span>(&#123;</span><br><span class="line">    model,</span><br><span class="line">    instructions,</span><br><span class="line">    input,</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">let</span> pickedTestTag = response.<span class="property">output_text</span>.<span class="title function_">trim</span>()</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;model %s response:\n%s\n&#x27;</span>, model, pickedTestTag)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;response usage:&#x27;</span>)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(response.<span class="property">usage</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// parse test tags and confidence scores</span></span><br><span class="line">  <span class="comment">// into the variable pickedTestTags</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (pickedTestTags.<span class="property">length</span> === <span class="number">0</span>) &#123;</span><br><span class="line">    <span class="comment">// if the tag is not in the list, use @sanity</span></span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">warn</span>(<span class="string">`Could not pick any known tags. Using @sanity instead.`</span>)</span><br><span class="line">    pickedTestTags.<span class="title function_">push</span>(&#123; <span class="attr">tag</span>: <span class="string">&#x27;@smoke&#x27;</span>, <span class="attr">confidence</span>: <span class="number">1</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// set actions outputs</span></span><br><span class="line">  <span class="keyword">const</span> pickedTags = pickedTestTags.<span class="title function_">map</span>(<span class="function">(<span class="params">tag</span>) =&gt;</span> tag.<span class="property">tag</span>).<span class="title function_">join</span>(<span class="string">&#x27;,&#x27;</span>)</span><br><span class="line">  core.<span class="title function_">setOutput</span>(<span class="string">&#x27;testTag&#x27;</span>, pickedTags)</span><br><span class="line">  core.<span class="title function_">setOutput</span>(<span class="string">&#x27;inputTokens&#x27;</span>, response.<span class="property">usage</span>.<span class="property">input_tokens</span>)</span><br><span class="line">  core.<span class="title function_">setOutput</span>(<span class="string">&#x27;outputTokens&#x27;</span>, response.<span class="property">usage</span>.<span class="property">output_tokens</span>)</span><br><span class="line">  core.<span class="title function_">setOutput</span>(<span class="string">&#x27;totalTokens&#x27;</span>, response.<span class="property">usage</span>.<span class="property">total_tokens</span>)</span><br><span class="line">  core.<span class="title function_">setOutput</span>(<span class="string">&#x27;model&#x27;</span>, model)</span><br><span class="line"></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Returning test tag: %s&#x27;</span>, pickedTags)</span><br><span class="line">  <span class="keyword">return</span> pickedTags</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> testTagsText = <span class="title class_">Object</span>.<span class="title function_">entries</span>(<span class="variable constant_">TEST_TAGS</span>)</span><br><span class="line">  .<span class="title function_">map</span>(<span class="function">(<span class="params">[tag, desc]</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="string">`  <span class="subst">$&#123;tag&#125;</span> <span class="subst">$&#123;desc&#125;</span>`</span></span><br><span class="line">  &#125;)</span><br><span class="line">  .<span class="title function_">join</span>(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> instructions =</span><br><span class="line">  <span class="string">`Given the following end-to-end test tags:</span></span><br><span class="line"><span class="string"><span class="subst">$&#123;testTagsText&#125;</span></span></span><br><span class="line"><span class="string">`</span> +</span><br><span class="line">  <span class="string">`Determine which test tag is applicable to the following code changes.</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  Return the list of all applicable test tags, one test tag per line.</span></span><br><span class="line"><span class="string">  In addition to the test tag, print the confidence score for each tag in parentheses, from 0 to 1, where 1 is the highest confidence.</span></span><br><span class="line"><span class="string">  For example:</span></span><br><span class="line"><span class="string">    @edit (0.9)</span></span><br><span class="line"><span class="string">    @persistence (0.8)</span></span><br><span class="line"><span class="string">    @add (0.3)</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  If no test tag is applicable, return &quot;@smoke (1.0)&quot;.</span></span><br><span class="line"><span class="string">`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> input = process.<span class="property">env</span>[<span class="string">&#x27;USER_TEXT&#x27;</span>]</span><br><span class="line"><span class="keyword">if</span> (!input) &#123;</span><br><span class="line">  <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(</span><br><span class="line">    <span class="string">&#x27;USER_TEXT environment variable is required. This should be a string with the pull request title and body&#x27;</span>,</span><br><span class="line">  )</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">let</span> openAiApiKey = process.<span class="property">env</span>[<span class="string">&#x27;OPEN_AI_API_KEY&#x27;</span>]</span><br><span class="line"><span class="keyword">if</span> (!openAiApiKey) &#123;</span><br><span class="line">  <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">&#x27;OPEN_AI_API_KEY environment variable is required&#x27;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// output logging into error stream</span></span><br><span class="line"><span class="keyword">const</span> separator = <span class="string">&#x27;=====&#x27;</span></span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Asking OpenAI using the instructions and input below...&#x27;</span>)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(input)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(separator)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(instructions)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(separator)</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * This exported function can be called by the GitHub Action</span></span><br><span class="line"><span class="comment"> * or from the command line.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = <span class="keyword">async</span> (&#123; core, <span class="title class_">OpenAI</span> &#125;) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> client = <span class="keyword">new</span> <span class="title class_">OpenAI</span>(&#123;</span><br><span class="line">    <span class="attr">apiKey</span>: openAiApiKey,</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> answer = <span class="keyword">await</span> <span class="title function_">ask</span>(instructions, input, core, client)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// log just the answer - a single test tag or several test tags separated by commas</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(answer)</span><br><span class="line">  <span class="keyword">return</span> answer</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>This script can be called from the command line or from a reusable GitHub Action, here is my YML file</p><figure class="highlight yml"><figcaption><span>.github/actions/pick-test-tag/action.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">Find</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">Suggests</span> <span class="string">a</span> <span class="string">web</span> <span class="string">test</span> <span class="string">tag</span> <span class="string">based</span> <span class="string">on</span> <span class="string">the</span> <span class="string">issue</span> <span class="string">title</span> <span class="string">and</span> <span class="string">body</span></span><br><span class="line"></span><br><span class="line"><span class="attr">inputs:</span></span><br><span class="line">  <span class="attr">title:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Issue title&#x27;</span></span><br><span class="line">    <span class="attr">type:</span> <span class="string">string</span></span><br><span class="line">    <span class="attr">required:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">body:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Issue body&#x27;</span></span><br><span class="line">    <span class="attr">type:</span> <span class="string">string</span></span><br><span class="line">    <span class="attr">required:</span> <span class="literal">false</span></span><br><span class="line">    <span class="attr">default:</span> <span class="string">&#x27;&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="attr">outputs:</span></span><br><span class="line">  <span class="attr">testTag:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Recommended test tag&#x27;</span></span><br><span class="line">    <span class="attr">value:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">inputTokens:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Number of input tokens used&#x27;</span></span><br><span class="line">    <span class="attr">value:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.inputTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">outputTokens:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Number of output tokens used&#x27;</span></span><br><span class="line">    <span class="attr">value:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.outputTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">totalTokens:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Total number of tokens used&#x27;</span></span><br><span class="line">    <span class="attr">value:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.totalTokens</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">model:</span></span><br><span class="line">    <span class="attr">description:</span> <span class="string">&#x27;Model used for the request&#x27;</span></span><br><span class="line">    <span class="attr">value:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.find.outputs.model</span> <span class="string">&#125;&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="attr">runs:</span></span><br><span class="line">  <span class="attr">using:</span> <span class="string">&#x27;composite&#x27;</span></span><br><span class="line">  <span class="attr">steps:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/setup-node@v4</span></span><br><span class="line">      <span class="attr">with:</span></span><br><span class="line">        <span class="attr">node-version:</span> <span class="number">22</span></span><br><span class="line"></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Install</span> <span class="string">**limited**</span> <span class="string">dependencies</span> <span class="string">📦</span></span><br><span class="line">      <span class="comment"># only install the packages needed to run the script</span></span><br><span class="line">      <span class="attr">run:</span> <span class="string">npm</span> <span class="string">install</span> <span class="string">openai</span></span><br><span class="line">      <span class="attr">shell:</span> <span class="string">bash</span></span><br><span class="line"></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Determine</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">      <span class="attr">id:</span> <span class="string">find</span></span><br><span class="line">      <span class="comment"># <span class="doctag">note:</span> this step produces multiple outputs</span></span><br><span class="line">      <span class="comment">#   - testTag</span></span><br><span class="line">      <span class="comment">#   - inputTokens</span></span><br><span class="line">      <span class="comment">#   - outputTokens</span></span><br><span class="line">      <span class="comment">#   - totalTokens</span></span><br><span class="line">      <span class="comment">#   - model</span></span><br><span class="line">      <span class="comment"># https://github.com/actions/github-script</span></span><br><span class="line">      <span class="attr">uses:</span> <span class="string">actions/github-script@v8</span></span><br><span class="line">      <span class="attr">with:</span></span><br><span class="line">        <span class="attr">script:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          const OpenAI = require(&#x27;openai&#x27;)</span></span><br><span class="line"><span class="string">          const pick = require(&#x27;$&#123;&#123; github.action_path &#125;&#125;/pick.js&#x27;);</span></span><br><span class="line"><span class="string">          await pick(&#123; core, OpenAI &#125;);</span></span><br><span class="line"><span class="string"></span>      <span class="attr">env:</span></span><br><span class="line">        <span class="comment"># hopefully the text does not have double quotes</span></span><br><span class="line">        <span class="attr">USER_TEXT:</span> <span class="string">&quot;$<span class="template-variable">&#123;&#123; inputs.title &#125;&#125;</span>\n\n$<span class="template-variable">&#123;&#123; inputs.body &#125;&#125;</span>&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">the</span> <span class="string">determined</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">      <span class="attr">shell:</span> <span class="string">bash</span></span><br><span class="line">      <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line">        <span class="string">echo</span> <span class="string">&quot;The recommended test tag is: $<span class="template-variable">&#123;&#123; steps.find.outputs.testTag &#125;&#125;</span>&quot;</span> <span class="string">&gt;&gt;</span> <span class="string">$GITHUB_STEP_SUMMARY</span></span><br></pre></td></tr></table></figure><p>Great, so what does it find?</p><p><img src="../images/ai-picks-tests/picked.png" alt="The picked testing tag"></p><p>Based on the user&#39;s description of the problem &quot;toggle does not work&quot;, the LLM picked the testing tag <code>@complete</code>. Its description &quot;Tests related to completing tasks and checking&#x2F;unchecking&quot; was the best match to the user text. Personally, I found LLMs to be hit or miss for <em>creating</em> new code, but pretty accurate for <em>picking</em> one of the limited number of variants. After all, the second &quot;L&quot; in LLM stands for &quot;language&quot;, it better do such semantic language matches well!</p><p>I even believe that small local LLMs can solve this &quot;pick the closes text&quot; problem, but don&#39;t have any proof.</p><h2><span id="running-the-picked-tests">Running the picked tests</span></h2><p>Once we picked just one testing tag <code>@complete</code> with 100% confidence, we execute it using the <a href="https://github.com/cypress-io/github-action">Cypress GitHub Action</a> that I wrote back in the day. Our project uses my plugin <a href="https://github.com/bahmutov/cypress-set-github-status">cypress-set-github-status</a> to post the individual spec results back into the original comment:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Run</span> <span class="string">tagged</span> <span class="string">tests</span> <span class="string">🧪</span></span><br><span class="line">  <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">  <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line">  <span class="attr">with:</span></span><br><span class="line">    <span class="comment"># let&#x27;s see which specs and tests we will run</span></span><br><span class="line">    <span class="attr">build:</span> <span class="string">npx</span> <span class="string">find-cypress-specs</span> <span class="string">--names</span> <span class="string">--tagged</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.pick-test-tag.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">start:</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">start:ci</span></span><br><span class="line">    <span class="attr">wait-on:</span> <span class="string">&#x27;http://localhost:8888&#x27;</span></span><br><span class="line">  <span class="attr">env:</span></span><br><span class="line">    <span class="comment"># pass the picked testing tag(s) to the @bahmutov/cy-grep plugin</span></span><br><span class="line">    <span class="attr">CYPRESS_grepTags:</span> <span class="string">$&#123;&#123;</span> <span class="string">needs.pick-test-tag.outputs.testTag</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="comment"># put test results into the comment</span></span><br><span class="line">    <span class="comment"># https://github.com/bahmutov/cypress-set-github-status</span></span><br><span class="line">    <span class="attr">GITHUB_TOKEN:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">COMMENT_ID:</span> <span class="string">$&#123;&#123;</span> <span class="string">steps.comment.outputs.comment-id</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>Here is the relevant Cypress config code</p><figure class="highlight js"><figcaption><span>cypress.config.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// other config code</span></span><br><span class="line"><span class="title function_">setupNodeEvents</span>(<span class="params">on, config</span>) &#123;</span><br><span class="line">  <span class="comment">// if needed, write the test results back into a GitHub comment</span></span><br><span class="line">  <span class="keyword">const</span> token = process.<span class="property">env</span>.<span class="property">GITHUB_TOKEN</span></span><br><span class="line">  <span class="keyword">const</span> comment = process.<span class="property">env</span>.<span class="property">COMMENT_ID</span></span><br><span class="line">  <span class="keyword">if</span> (token &amp;&amp; comment) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(</span><br><span class="line">      <span class="string">&#x27;Will write test results into the comment with id %s&#x27;</span>,</span><br><span class="line">      comment,</span><br><span class="line">    )</span><br><span class="line">    <span class="built_in">require</span>(<span class="string">&#x27;cypress-set-github-status&#x27;</span>)(on, config, &#123;</span><br><span class="line">      <span class="attr">owner</span>: <span class="string">&#x27;bahmutov&#x27;</span>,</span><br><span class="line">      <span class="attr">repo</span>: <span class="string">&#x27;run-tests-on-a-bug&#x27;</span>,</span><br><span class="line">      token,</span><br><span class="line">      comment,</span><br><span class="line">    &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// optional: register cy-grep plugin code</span></span><br><span class="line">  <span class="comment">// https://github.com/bahmutov/cy-grep</span></span><br><span class="line">  <span class="built_in">require</span>(<span class="string">&#x27;@bahmutov/cy-grep/src/plugin&#x27;</span>)(config)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// make sure to return the config object</span></span><br><span class="line">  <span class="comment">// as it might have been modified by the plugin</span></span><br><span class="line">  <span class="keyword">return</span> config</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Once the test results come in, the original issue comment is updated with details: 2 tests failed.</p><p><img src="../images/ai-picks-tests/test-comment.png" alt="The comment is updated with the failed test titles"></p><p>If our project was recording test traces on the Cypress Dashboard, the comment would include a link to the run URL. For now, we simply go to the GitHub Actions run URL and download the screenshots or videos of the test run.</p><p><img src="../images/ai-picks-tests/test-artifacts.png" alt="GitHub Actions run job summary has the links to the screenshots and videos"></p><p>Let&#39;s download the screenshots. Hmm, the failed test clicked on the &quot;Toggle All&quot; button, yet each item remained incomplete. The test result points us in the right direction; we should be looking at the JavaScript code that is executed in response to the user&#39;s click on the &quot;Toggle All&quot; element.</p><p><img src="../images/ai-picks-tests/failed-test.png" alt="Cypress failed test screenshot"></p><p>Great. We automatically ran the relevant tests based on the user&#39;s input, collecting lots of information that should help us quickly debug the problem and deploy a fix.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;In the blog post &lt;a href=&quot;/blog/test-tag-suggestions-using-ai/&quot; title=&quot;Test Tag Suggestions Using AI&quot;&gt;Test Tag Suggestions Using AI&lt;/a&gt; I
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="github" scheme="https://glebbahmutov.com/blog/tags/github/"/>
    
  </entry>
  
  <entry>
    <title>Use Copilot Instructions And Page Objects</title>
    <link href="https://glebbahmutov.com/blog/copilot-instructions-and-page-objects/"/>
    <id>https://glebbahmutov.com/blog/copilot-instructions-and-page-objects/</id>
    <published>2025-09-03T04:00:00.000Z</published>
    <updated>2025-10-09T18:06:35.545Z</updated>
    
    <content type="html"><![CDATA[<p>Ask Copilot agent to write the full end-to-end test and it is likely to write nonsense. For example, let&#39;s test the &quot;zero teams &#x2F; zero players&quot; message on my <a href="https://glebbahmutov.com/gametime/">soccer web app</a>.</p><p><img src="../images/copilot-instructions-and-page-objects/teams.png" alt="Zero teams and zero players messages"></p><p>I can start a new test in the existing &quot;Teams&quot; spec file</p><figure class="highlight js"><figcaption><span>cypress/e2e/teams.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Teams&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  it.<span class="title function_">only</span>(<span class="string">&#x27;shows zero teams and zero players message&#x27;</span>, <span class="function">() =&gt;</span> &#123;&#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>If I ask Copilot to write this test, what are the chances of Copilot writing a good end-to-end test?</p><p><img src="../images/copilot-instructions-and-page-objects/prompt1.png" alt="Prompting Copilot to write this test"></p><p>The agent&#39;s solution is not too bad</p><figure class="highlight js"><figcaption><span>cypress/e2e/teams.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress&quot; /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Teams&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  it.<span class="title function_">only</span>(<span class="string">&#x27;shows zero teams and zero players message&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// Visit the teams page (adjust route if needed)</span></span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/team&#x27;</span>)</span><br><span class="line">    <span class="comment">// Check for zero teams message</span></span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="regexp">/zero teams/i</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">    <span class="comment">// Check for zero players message</span></span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="regexp">/zero players/i</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Let&#39;s immediately run this test to see how well it works.</p><p><img src="../images/copilot-instructions-and-page-objects/test1.png" alt="Cypress running the test above"></p><p>The test is failing</p><ul><li>the check for zero teams message is wrong. Copilot does not &quot;know&quot; what the &quot;zero teams&quot; selector should be</li><li>the test is incomplete. It does not check if the &quot;zero teams&quot; component goes away when we add a team</li></ul><p>Copilot has <em>no idea</em> about your project. It is like a developer who just sees the spec file and &quot;guesses&quot; the test steps and selectors based on their previous work experience, but without any idea how <em>your</em> project works. Let&#39;s improve it.</p><h2><span id="add-comments">Add comments</span></h2><p>Notice how Copilot added comments to the generated test code? That&#39;s a very good idea to describe what the test is trying to do. We should guide Copilot using comments ourselves.</p><figure class="highlight js"><figcaption><span>cypress/e2e/teams.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Teams&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows zero teams and zero players message&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// Visit the team page</span></span><br><span class="line">    <span class="comment">// Check if the zero teams and zero players components are visible</span></span><br><span class="line">    <span class="comment">// Add a team</span></span><br><span class="line">    <span class="comment">// Confirm the zero teams component is gone</span></span><br><span class="line">    <span class="comment">// zero players should still be visible</span></span><br><span class="line">    <span class="comment">// Add a player</span></span><br><span class="line">    <span class="comment">// Confirm the zero players component is gone</span></span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Using code comments in my opinion is preferably to putting more context into the Agent prompt, since prompts are transient, while the comments stay with the code.</p><p>Let&#39;s use the same prompt and check the generated code.</p><p><img src="../images/copilot-instructions-and-page-objects/prompt2.png" alt="Copilot prompt plus test comments"></p><p>Much much better. Does it work?</p><p><img src="../images/copilot-instructions-and-page-objects/test2.png" alt="Almost a half of the second test works"></p><p>Let&#39;s give Copilot shortcuts.</p><h2><span id="use-a-page-object">Use a page object</span></h2><p>Instead of hoping that Copilot can discover in our test how to add a team and a player, why don&#39;t we give it a shortcut: by using a page object we can simplify Copilot&#39;s task. I will create a new static object that simply implements a few common actions on the page, like adding a team.</p><figure class="highlight ts"><figcaption><span>cypress/e2e/gametime.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="string">&#x27;cypress-plugin-steps&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title class_">Gametime</span> = &#123;</span><br><span class="line">  <span class="title function_">home</span>(<span class="params"></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">teamPage</span>(<span class="params"></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">visit</span>(<span class="string">&#x27;/team/&#x27;</span>)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">addTeam</span>(<span class="params">name: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">step</span>(<span class="string">`Add team <span class="subst">$&#123;name&#125;</span>`</span>)</span><br><span class="line">    cy.<span class="title function_">location</span>(<span class="string">&#x27;pathname&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;include&#x27;</span>, <span class="string">&#x27;/team/&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=teamName]&#x27;</span>).<span class="title function_">type</span>(name)</span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;Add team&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-teams&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;li.team&#x27;</span>).<span class="title function_">find</span>(<span class="string">&#x27;.name&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, name)</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="title function_">addPlayer</span>(<span class="params">first: <span class="built_in">string</span>, last: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">    cy.<span class="title function_">step</span>(<span class="string">`Add player <span class="subst">$&#123;first&#125;</span> <span class="subst">$&#123;last&#125;</span>`</span>)</span><br><span class="line">    cy.<span class="title function_">location</span>(<span class="string">&#x27;pathname&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;include&#x27;</span>, <span class="string">&#x27;/team/&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=firstName]&#x27;</span>).<span class="title function_">type</span>(first)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[name=lastName]&#x27;</span>).<span class="title function_">type</span>(last)</span><br><span class="line">    cy.<span class="title function_">contains</span>(<span class="string">&#x27;button&#x27;</span>, <span class="string">&#x27;+&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;li.player&#x27;</span>)</span><br><span class="line">      .<span class="title function_">find</span>(<span class="string">&#x27;[data-cy=&quot;name&quot;]&#x27;</span>)</span><br><span class="line">      .<span class="title function_">should</span>(<span class="string">&#x27;have.text&#x27;</span>, <span class="string">`<span class="subst">$&#123;first&#125;</span> <span class="subst">$&#123;last&#125;</span>`</span>)</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">Gametime</span></span><br></pre></td></tr></table></figure><p><strong>Tip:</strong> I am using <a href="https://github.com/filiphric/cypress-plugin-steps">cypress-plugin-steps</a> to create better <a href="/blog/cypress-steps-plugin/" title="visual test log">visual test log</a>.</p><p>Ok, so how can Copilot take the advantage of &quot;gametime&quot; page object? Let&#39;s include it in the prompt.</p><p><img src="../images/copilot-instructions-and-page-objects/prompt3.png" alt="Copilot prompt including the page object reference"></p><p>Bingo. The generated test looks reasonable and is passing.</p><p><img src="../images/copilot-instructions-and-page-objects/test3.png" alt="The generated test is working"></p><h2><span id="use-copilot-instructions-file">Use Copilot instructions file</span></h2><p>Typing the same &quot;Use the page object from gametime.ts file to do common actions.&quot; in each prompt quickly becomes tiresome. We can use the common <a href="https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions">Copilot instructions Markdown</a> file instead. Here is my instructions file that gives Copilot general instructions for code generation. Notice how I use the project-specific references.</p><p><img src="../images/copilot-instructions-and-page-objects/instructions.png" alt="Copilot instructions file"></p><p>Let&#39;s see if Copilot Agent can write a useful test with the minimal prompt &quot;Implement this test&quot;. Wow, the Agent actually does the two-step. First it suggests using <em>only</em> the page object.</p><p><img src="../images/copilot-instructions-and-page-objects/prompt4a.png" alt="The agent generates the initial code"></p><p>The generated test is bad, but the Copilot Agent is not done yet. It now checks if the syntax is correct and modifies the code to mix page object method calls with custom UI assertions!</p><p><img src="../images/copilot-instructions-and-page-objects/prompt4b.png" alt="The agent finished generating the test"></p><p>The finished test is ... good and passing.</p><p><img src="../images/copilot-instructions-and-page-objects/test4.png" alt="The passing final test"></p><p>Here is the finished spec file for reference</p><figure class="highlight ts"><figcaption><span>cypress/e2e/teams.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// &lt;reference types=&quot;cypress&quot; /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// @ts-check</span></span><br><span class="line"><span class="keyword">import</span> gametime <span class="keyword">from</span> <span class="string">&#x27;./gametime&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">&#x27;Teams&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">it</span>(<span class="string">&#x27;shows zero teams and zero players message&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// Visit the team page</span></span><br><span class="line">    gametime.<span class="title function_">teamPage</span>()</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Check if the zero teams and zero players components are visible</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-teams&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-players&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Add a team</span></span><br><span class="line">    gametime.<span class="title function_">addTeam</span>(<span class="string">&#x27;Team 1&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Confirm the zero teams component is gone</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-teams&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">    <span class="comment">// zero players should still be visible</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-players&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;be.visible&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Add a player</span></span><br><span class="line">    gametime.<span class="title function_">addPlayer</span>(<span class="string">&#x27;Player&#x27;</span>, <span class="string">&#x27;One&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Confirm the zero players component is gone</span></span><br><span class="line">    cy.<span class="title function_">get</span>(<span class="string">&#x27;[data-cy=&quot;zero-players&quot;]&#x27;</span>).<span class="title function_">should</span>(<span class="string">&#x27;not.exist&#x27;</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Pretty sweet. I would accept this test if someone opened a pull request review with this code. It uses mostly page object methods for general actions on the page plus correct stable selectors for checking the &quot;zero&quot; components. No complains.</p><blockquote class="pullquote"><p>🎓 Want to learn how to use Cypress and Copilot or Cursor to quickly write useful end-to-end tests? Check out my online courses on these topics at <a href="https://cypress.tips/courses">https://cypress.tips/courses</a>.</p></blockquote><h2><span id="tips">Tips</span></h2><h3><span id="add-a-typescript-check">Add a TypeScript check</span></h3><p>Often Copilot &quot;invents&quot; non-existent page object methods</p><p><img src="../images/copilot-instructions-and-page-objects/method.png" alt="Copilot Agent suggests a non-existent method"></p><p>To prevent such simple hallucinations, I include the following in my prompt &#x2F; instructions</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">When using the gametime.ts page object, check if the method names and their parameters are correct.</span><br><span class="line">There should be no TypeScript warnings or &quot;any&quot; unknown types in the generated code.</span><br></pre></td></tr></table></figure><p>I also use <code>// @ts-check</code> in my spec files to type-check even JavaScript specs.</p><h2><span id="see-also">See also</span></h2><ul><li><a href="/blog/copilot-instructions-example/" title="Copilot Instructions Example">Copilot Instructions Example</a></li></ul>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ask Copilot agent to write the full end-to-end test and it is likely to write nonsense. For example, let&amp;#39;s test the &amp;quot;zero teams 
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="copilot" scheme="https://glebbahmutov.com/blog/tags/copilot/"/>
    
  </entry>
  
  <entry>
    <title>Useful Newsletters</title>
    <link href="https://glebbahmutov.com/blog/useful-newsletters/"/>
    <id>https://glebbahmutov.com/blog/useful-newsletters/</id>
    <published>2025-08-20T04:00:00.000Z</published>
    <updated>2025-09-24T02:08:13.233Z</updated>
    
    <content type="html"><![CDATA[<p>Here are a couple of newsletters I subscribe to keep up-to-date on Node.js, JavaScript, front-end development, and testing.</p><ul><li><a href="https://www.smashingmagazine.com/the-smashing-newsletter/">Smashing Magazine newsletter</a> has high-quality front-end, CSS, and design articles</li><li><a href="https://webtoolsweekly.com/">WebTools weekly</a> is a weekly round-up of apps, scripts, plugins, and other resources to help front-end and full-stack web developers solve problems and build great websites.</li><li><a href="https://javascriptweekly.com/">JavaScript Weekly</a> is a newsletter of JavaScript articles, news and cool projects</li><li><a href="https://webweekly.email/">Web Weekly newsletter</a> is a small newsletter published by <a href="https://www.stefanjudis.com/">Stefan Judis</a></li><li><a href="https://nodeweekly.com/">Node Weekly</a> is a free, once–weekly e-mail round-up of Node.js news and articles.</li><li>my own <a href="https://cypresstips.substack.com/">Cypress Testing Tips &amp; Tricks</a> monthly edition lets you keep up with my recipes, blog posts, and videos on how to use Cypress for end-to-end testing</li><li><a href="https://frontendfoc.us/">Frontend Focus</a> is a once–weekly roundup of the best front-end news, articles and tutorials. HTML, CSS, WebGL, Canvas, browser tech, and more.</li><li><a href="https://pointerio.beehiiv.com/">Pointer</a> advertises itself as an &quot;Essential Reading For Engineering Leaders&quot;</li><li><a href="https://softwareleadweekly.com/">Software Lead Weekly</a> is a weekly email for busy people who care about people, culture and leadership.</li></ul><h2><span id="newsletters-focused-on-testing">Newsletters focused on testing</span></h2><ul><li><a href="https://softwaretestingweekly.com/">Software Testing Weekly</a> is all about QA and testing. My posts are regularly featured.</li><li><a href="https://softwaretestingnotes.substack.com/">Software Testing Notes</a> is a weekly newsletter with the best links on Software Testing.</li><li><a href="https://testingconferences.org/">Software Testing Conferences</a> is an up to date list of Software Testing conferences and workshop.</li></ul><h2><span id="submitted-by-my-readers">Submitted by my readers</span></h2><ul><li><a href="https://thisweekinreact.com/">This Week In React</a> if you are into React or React Native</li><li><a href="https://ecmascript.news/">Logo ECMAScript News</a> is the newsletter on JavaScript and tools</li></ul>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Here are a couple of newsletters I subscribe to keep up-to-date on Node.js, JavaScript, front-end development, and testing.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
  </entry>
  
  <entry>
    <title>Does This Look Right To You, AI?</title>
    <link href="https://glebbahmutov.com/blog/does-this-look-right-to-you-ai/"/>
    <id>https://glebbahmutov.com/blog/does-this-look-right-to-you-ai/</id>
    <published>2025-07-16T04:00:00.000Z</published>
    <updated>2025-07-16T15:21:18.516Z</updated>
    
    <content type="html"><![CDATA[<p>How does a human quality assurance engineer (QA) test a web application? If I ask you to look at this TodoMVC web app, how do you know what features it has?</p><p><img src="../images/does-this-look-right-to-you-ai/app.png" alt="Example TodoMVC app"></p><p>From your life experience, you might guess that this app lets the user enter new Todo items and should show them in the list somewhere below the input element. This knowledge comes from you seeing 100s of &quot;TodoMVC&quot; apps in your life. But what about this crescent symbol? Does it mean anything?</p><p><img src="../images/does-this-look-right-to-you-ai/crescent.png" alt="What does this symbol do, if anything?"></p><p>Hmm, the only way to know what to expect and how to test this part of the application is to read the product description of the application. Otherwise, you will simply be guessing!</p><h2><span id="ai-testing">AI testing</span></h2><p>Let me clear one thing right away: giving an AI an URL and asking it to write 5-10 end-to-end tests is a pointless task. I don&#39;t think it will work any time soon. But if we give an AI agent specific instructions and supporting information, like the application source code, it can create an accurate test. Let&#39;s confirm the user can enter an item:</p><p><img src="../images/does-this-look-right-to-you-ai/copilot-agent.png" alt="GitHub Copilot prompt to write a test with the generated result"></p><p>The result is a passing end-to-end test</p><p><img src="../images/does-this-look-right-to-you-ai/copilot-result.png" alt="GitHub Copilot wrote a passing test"></p><p>Notice how I gave the agent the context: the &quot;todo&quot; folder that contains the source code for the application. What happens if the agent does not have the source code OR cannot trace how the front-end generates the DOM from the data? Let&#39;s say I remove the <code>placeholder</code> attribute from the HTML input element. Without &quot;todo&quot; folder, the LLM &quot;guesses&quot; based on its trained model that the input element in the typical TodoMVC app has the placeholder attribute!</p><p><img src="../images/does-this-look-right-to-you-ai/placeholder.png" alt="AI guesses elements to use"></p><p>Hmm, there is no input element with this placeholder; so the test fails</p><p><img src="../images/does-this-look-right-to-you-ai/placeholder-error.png" alt="AI guessed wrong. There is no such input element"></p><h2><span id="product-description">Product description</span></h2><p>Should our application have an input element like this <code>&lt;input placeholder=&quot;Create a new todo...&quot; ...&gt;</code>? Here are two pieces of advice I want to give in this blog post:</p><ol><li>the description of what the user should see on the page (in the DOM) should come from the product feature description text document</li><li>the AI should check <em>generated DOM</em> rather than understand how the application will produce it</li></ol><p>Let&#39;s describe our TodoMVC&#39;s main feature: adding todos. I will simply add a Markdown &quot;todo&#x2F;readme.md&quot; file with the description of application&#39;s &quot;add&quot; feature. Then I can reference this file when prompting the AI agent to write the same test</p><p><img src="../images/does-this-look-right-to-you-ai/readme1.png" alt="AI prompt that uses product description"></p><p>The generated test does <em>not</em> look at the application implementation. It simply follows the user-facing properties from the README file.</p><p><img src="../images/does-this-look-right-to-you-ai/readme2.png" alt="AI-generated test following the product description"></p><p>Since our web application implementation was <em>correct</em>, the test is passing; both the input and the list elements have the expected values.</p><p><img src="../images/does-this-look-right-to-you-ai/readme3.png" alt="AI-generated test following the product description is correct"></p><h2><span id="does-this-look-wrong">Does this look wrong?</span></h2><p>Now, let&#39;s leap to the new heights.</p><p>Imagine that the product owner decides that your TodoMVC web app should be loud. It should show the Todo items in all uppercase letters. The product owner updates the app description</p><figure class="highlight md"><figcaption><span>todo/readme.md</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">## Features</span></span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> when the user enters a todo in the input field with <span class="code">`data-cy=&quot;add-todo&quot;`</span> and presses &quot;Enter&quot;,</span><br><span class="line">the new todo item appears in the list of todos. Each todo should have attribute <span class="code">`data-cy=&quot;todo&quot;`</span></span><br><span class="line"><span class="bullet">-</span> no matter how the user enters the input, the list of todos should show</span><br><span class="line">each item using all uppercase letters</span><br></pre></td></tr></table></figure><p>Great. Do not write &#x2F; update the web test yet! Imagine you are a human QA. Given this updated product description, could you tell if the app is correct or not? Does this look wrong to you?</p><p><img src="../images/does-this-look-right-to-you-ai/wrong.png" alt="Does the application confirm to the product description?"></p><p>As a human, I can tell that the app is <em>wrong</em>. The entered todo item &quot;Buy milk&quot; is NOT shown in all uppercase. I do not need to look into the application source code to know that. I simply see the result in the rendered DOM &#x2F; HTML of the app that part of the end-to-end test &quot;adds 1 todo&quot;.</p><p><img src="../images/does-this-look-right-to-you-ai/html.png" alt="Items are clearly present in the page DOM"></p><p>I can do it as a human being. Even in unrelated end-to-end tests, I can look at the result of each step and see that something is wrong: the application does not satisfy the product description!</p><p>Can an AI agent detect that something is wrong?</p><h2><span id="ai-able-end-to-end-test-trace">AI-able end-to-end test trace</span></h2><p>Rather than start writing MCP servers, I think we should work on making more tools product machine-readable or as I call it &quot;AI-able&quot; output. For example, I have written a simply Cypress logger than produces a summary plus the DOM snapshot after each command. Let&#39;s run the test again and look at the produced test JSON log</p><p><img src="../images/does-this-look-right-to-you-ai/output-log.png" alt="AI-able e2e test output log JSON"></p><p>Great. Now comes the beautiful part. I call a script file that calls an AI model and feeds it the following prompt:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Given the follow application feature descriptions</span><br><span class="line">confirm the web app shows the expected DOM HTML elements after each test step.</span><br><span class="line"></span><br><span class="line">&#123;tood/readme.md&#125; contents</span><br><span class="line"></span><br><span class="line">&#123;join all steps&#125;</span><br><span class="line">  step: &#123;step.name&#125; &#123;step.args.join(&#x27; &#x27;)&#125;</span><br><span class="line"></span><br><span class="line">  The HTML snapshot of the page after the step was executed is:</span><br><span class="line">  &#123;step.html&#125;</span><br></pre></td></tr></table></figure><p>So we create a long input of text with HTML of the page after each command finishes. Here is what GPT-4.1 model tells us about our application:</p><p><img src="../images/does-this-look-right-to-you-ai/ai1.png" alt="AI detects a problem in our DOM against product description"></p><p>Having a DOM snapshot makes is <em>very</em> easy for LLM to check the web application and even explain the expected result - since it knows the entered Todo item &quot;Buy milk&quot;</p><p><img src="../images/does-this-look-right-to-you-ai/ai2.png" alt="AI explains the problem in the DOM contents"></p><p>Notice, we haven&#39;t even modified the test, we simply &quot;looked&quot; at the web application and checked if it satisfies the product description invariants. Easy win.</p><h2><span id="ai-tdd-ai-driven-test-drive-development">AI-TDD: AI-Driven Test-Drive Development</span></h2><p>Let&#39;s implement the &quot;ALL CAPS&quot; feature. In the &quot;todo&#x2F;script.js&quot; I will change the DOM text</p><figure class="highlight js"><figcaption><span>todo/script.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// set todo item for card</span></span><br><span class="line">item.<span class="property">textContent</span> = todo.<span class="property">item</span>.<span class="title function_">toUpperCase</span>()</span><br></pre></td></tr></table></figure><p>Our E2E test is broken, but that&#39;s ok (for now).</p><p><img src="../images/does-this-look-right-to-you-ai/add1.png" alt="The end-to-end test has not been updated yet to account for uppercase list items"></p><p>But the application IS behaving according to its production specification!</p><p><img src="../images/does-this-look-right-to-you-ai/add2.png" alt="AI has checked the DOM against the product description at each step"></p><p>Now that AI has inspected the application and confirmed that is is correct, we can update the test to make the testing much faster and cheaper. We can even ask AI to update it using <em>the product description</em> of course!</p><p><img src="../images/does-this-look-right-to-you-ai/update-test1.png" alt="Use the product description to update the E2E Cypress test"></p><p>The updated test simply checks the uppercase text</p><p><img src="../images/does-this-look-right-to-you-ai/update-test2.png" alt="AI agent has updated the test"></p><p>The updated test is passing again</p><p><img src="../images/does-this-look-right-to-you-ai/update-test3.png" alt="Passing updated test"></p><p>Having good precise product spec is thus the key to coding the app, writing E2E tests, and checking the result at each step. Having a system that never tires &quot;looking&quot; at the DOM to check if <em>any</em> product features are broken, even in unrelated tests could be a pretty sweet deal. All you need to do is ask the AI: &quot;does this look right to you?&quot;</p><p>Happy testing.</p><blockquote class="pullquote"><p>👎 While I am not making the source code for this blog post public, you can still learn how I use GitHub Copilot and Cursor for writing end-to-end tests. Grab my online courses <a href="https://cypress.tips/courses/cy-copilot">Write Cypress Tests Using GitHub Copilot</a> and <a href="https://cypress.tips/courses/cy-cursor">Cursor AI For Cypress Testing</a>.</p></blockquote>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;How does a human quality assurance engineer (QA) test a web application? If I ask you to look at this TodoMVC web app, how do you know wh
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
  </entry>
  
  <entry>
    <title>Copilot Pull Request Reviews For Testability</title>
    <link href="https://glebbahmutov.com/blog/copilot-pull-request-reviews/"/>
    <id>https://glebbahmutov.com/blog/copilot-pull-request-reviews/</id>
    <published>2025-06-27T04:00:00.000Z</published>
    <updated>2025-06-27T20:55:42.364Z</updated>
    
    <content type="html"><![CDATA[<p>If you are using GitHub Copilot to <a href="https://github.blog/changelog/2025-05-19-github-copilot-coding-agent-in-public-preview/">review pull requests</a>, you can give it <a href="https://docs.github.com/en/copilot/how-tos/agents/copilot-code-review/using-copilot-code-review">specific instructions</a> via <code>.github/copilot-instructions.md</code>. I confirmed that using either an explicit list with every item starting with <code>When performing a code review, ...</code> or a combined section works.</p><p>This works:</p><figure class="highlight md"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">When performing a code review, confirm that there are no hard-coded magic numbers.</span><br><span class="line">Prefer using named constants.</span><br><span class="line"></span><br><span class="line">When performing a code review, do not allow unreachable code</span><br><span class="line"></span><br><span class="line">When performing a code review, check each HTML element that shows any unique application data,</span><br><span class="line">like prices, values, names, address, etc to have a <span class="code">`data-testid`</span> attribute to be used in end-to-end tests.</span><br><span class="line">If the attribute is missing, add a <span class="code">`data-testid`</span> attribute with a meaningful value.</span><br><span class="line">Also add <span class="code">`data-testid`</span> attributes to the top level forms, pages, large components.</span><br></pre></td></tr></table></figure><p>This also works:</p><figure class="highlight md"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">When performing a code review:</span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> confirm that there are no hard-coded magic numbers.</span><br><span class="line">  Prefer using named constants.</span><br><span class="line"><span class="bullet">-</span> do not allow unreachable code</span><br><span class="line"><span class="bullet">-</span> check each HTML element that shows any unique application data,</span><br><span class="line">  like prices, values, names, address, etc to have a <span class="code">`data-testid`</span> attribute</span><br><span class="line">  to be used in end-to-end tests. If the attribute is missing,</span><br><span class="line">  add a <span class="code">`data-testid`</span> attribute with a meaningful value.</span><br><span class="line">  Also add <span class="code">`data-testid`</span> attributes to the top level forms, pages,</span><br><span class="line">  large components.</span><br></pre></td></tr></table></figure><h2><span id="code-review-from-vscode">Code review from VSCode</span></h2><p>If you want to review the entire codebase, you can ask for the code review from VSCode using prompt like &quot;review the code in this repo as if doing a pull request review&quot;. Here is the review that finds that <code>index.html</code> file has a DIV with missing <code>data-testid</code> attribute</p><p><img src="../images/copilot-pull-request-reviews/vs-code-review.png" alt="Missing data-testid attribute"></p><h2><span id="pull-request-review">Pull request review</span></h2><p>If we open a pull request, and our Copilot plan has GitHun Copilot AI review included, we can assign an AI reviewer that will look at the source changes only. Following the Copilot instructions from the main branch, it can suggest similar fixes</p><p><img src="../images/copilot-pull-request-reviews/pr-review.png" alt="Missing data-testid attribute"></p><p>You can see the Copilot code review in action in my video below</p><div class="video-container"><iframe src="https://www.youtube.com/embed/rJvJu_yZUJE" frameborder="0" loading="lazy" allowfullscreen></iframe></div><h2><span id="missing-tests">Missing tests</span></h2><p>We saw how Copilot review can &quot;catch&quot; new HTML fields without <a href="/blog/how-i-add-test-ids/" title="meaningful test ids">meaningful test ids</a>. Now let&#39;s ensure that we do not add testable HTML elements without any E2E tests. I have added the following Copilot pull request instruction:</p><figure class="highlight md"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> when modifying front-end code, check all elements with <span class="code">`data-testid`</span> attributes</span><br><span class="line">to ensure that there is at least 2 end-to-end Cypress tests in <span class="code">`cypress/e2e`</span></span><br><span class="line">folder using those attributes.</span><br></pre></td></tr></table></figure><p>Here is a pull request where I add a new &quot;age&quot; field. Copilot sees two <code>data-testid</code> elements and correctly detects that the <code>data-testid=age</code> element does NOT have any E2E specs that reference it. Nice!</p><p><img src="../images/copilot-pull-request-reviews/pr.png" alt="An element needs end-to-end tests"></p><p>Is Copilot pull request worth it? I think yes. It does catch easy omissions, ensuring that the front-end code is testable and tested.</p><blockquote class="pullquote"><p>🎓 For more practice information on using GitHub Copilot, check out my online course &quot;Write Cypress Tests Using GitHub Copilot&quot; available at <a href="https://cypress.tips/courses">https://cypress.tips/courses</a></p></blockquote>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;If you are using GitHub Copilot to &lt;a href=&quot;https://github.blog/changelog/2025-05-19-github-copilot-coding-agent-in-public-preview/&quot;&gt;revi
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
      <category term="copilot" scheme="https://glebbahmutov.com/blog/tags/copilot/"/>
    
  </entry>
  
  <entry>
    <title>Build RAG Using Chroma DB</title>
    <link href="https://glebbahmutov.com/blog/build-rag-using-chroma-db/"/>
    <id>https://glebbahmutov.com/blog/build-rag-using-chroma-db/</id>
    <published>2025-06-24T04:00:00.000Z</published>
    <updated>2025-06-24T13:50:45.194Z</updated>
    
    <content type="html"><![CDATA[<p>Imagine you ask AI&#x2F;LLM how to do a particular task by describing what you want to accomplish in code comments.</p><figure class="highlight js"><figcaption><span>test-examples/spec2.cy.js</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// this is a Cypress end-to-end test</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;changes the label after the click&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  <span class="comment">// <span class="doctag">TODO:</span> there is label element with id &quot;foo&quot;</span></span><br><span class="line">  <span class="comment">// and a button with id &quot;bar&quot;</span></span><br><span class="line">  <span class="comment">// after clicking the button, the text in the label with id &quot;foo&quot; should change</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>You fire off Cursor or GitHub Copilot... and it might give you a good code suggestion or it might suggest absolute nonsense. Let&#39;s take this answer that used Claude Sonnet 3.5 model</p><p><img src="../images/chromadb/answer.png" alt="Test implementation suggested by Claude Sonnet 3.5"></p><p>For easy search, the suggestion used an alias to the old text</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">&#x27;changes the label after the click&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  cy.<span class="title function_">visit</span>(<span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">  <span class="comment">// get the initial label text and store it</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#foo&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invoke</span>(<span class="string">&#x27;text&#x27;</span>)</span><br><span class="line">    .<span class="title function_">as</span>(<span class="string">&#x27;initialText&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// click the button</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#bar&#x27;</span>).<span class="title function_">click</span>()</span><br><span class="line"></span><br><span class="line">  <span class="comment">// verify the label text has changed</span></span><br><span class="line">  cy.<span class="title function_">get</span>(<span class="string">&#x27;#foo&#x27;</span>)</span><br><span class="line">    .<span class="title function_">invoke</span>(<span class="string">&#x27;text&#x27;</span>)</span><br><span class="line">    .<span class="title function_">then</span>(<span class="function">(<span class="params">newText</span>) =&gt;</span> &#123;</span><br><span class="line">      cy.<span class="title function_">get</span>(<span class="string">&#x27;@initialText&#x27;</span>).<span class="title function_">then</span>(<span class="function">(<span class="params">initialText</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="title function_">expect</span>(newText).<span class="property">to</span>.<span class="property">not</span>.<span class="title function_">equal</span>(initialText)</span><br><span class="line">      &#125;)</span><br><span class="line">    &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Notice two things: the solution adopts the variable names and data from my prompt; it used the <code>#foo</code> and <code>#bar</code> selectors. Good. But the solution has a subtle bug in how it uses an aliased value that might make this test flakey depending on the timer, see <a href="https://glebbahmutov.com/cypress-examples/recipes/text-changes.html#using-a-static-alias">Text Changes</a> and the video below for details:</p><div class="video-container"><iframe src="https://www.youtube.com/embed/KkkMrzBi0mY" frameborder="0" loading="lazy" allowfullscreen></iframe></div><p>Can we improve the answer given by LLM?</p><h2><span id="give-good-examples">Give good examples</span></h2><p>Humans can do a lot following an example. This is why 11 years ago I suggested <a href="/blog/good-examples/" title="putting example comments">putting example comments</a> in your source code. A similar approach works with AI generation: prefix your question (prompt) with good examples showing how to achieve the desired outcome. For example, you can manually post the above recipe <a href="https://glebbahmutov.com/cypress-examples/recipes/text-changes.html">Text Changes</a> and then ask the LLM:</p><p><img src="../images/chromadb/prompt.png" alt="Prefix the question with a good relevant example"></p><p>Ok, what does LLM do now? It follows the good example!</p><p><img src="../images/chromadb/prompt-answer.png" alt="The same question answered when given a relevant example"></p><p>Even older models can generate high-quality code when provided good, well-tested, trustworthy examples and information.</p><h2><span id="trustworthy-information">Trustworthy information</span></h2><p>Unfortunately, the world wild web cannot be trusted to have accurate information. As someone who is reading a lot of blog posts, I notice a lot of incorrect examples and solutions that are missing important context, have hidden bugs, etc. LLMs trained across wider and wider swaths of Internet do not know which information is accurate, and which information is just some JavaScript snippet posted on a page.</p><p>The sourced of tested, accurate, up-to-date coding knowledge should be a premium. I am maintaining a few such knowledge databases for Cypress end-to-end tests. For example, <a href="https://glebbahmutov.com/cypress-examples">Cypress Examples</a> has almost 1000 constantly <strong>tested</strong> Cypress tests covering all <code>cy</code> commands and various testing situations.</p><p><img src="../images/chromadb/tests.png" alt="My cypress-examples repo has more than passing 800 Cypress tests"></p><p>Similarly, I have example repos that are constantly tested for my online courses like <a href="https://cypress.tips/courses/network-testing">Cypress Network Testing Examples</a> and <a href="http://cypress.tips/courses/cypress-plugins">Cypress Plugins</a>, etc. Each course has hundreds of lessons, thus the total number of high-quality Cypress tests is close to another 1000. How do we use them to answer any current prompts?</p><h2><span id="retrieval-augmented-generation">Retrieval-augmented Generation</span></h2><p>Easy. Before generating an answer to our current prompt, we find a similar example using semantic code and text search. Then we include the example in the full prompt we send to an LLM. This is what Retrieval (search for example) Augmented (and include it with your prompt) Generation (adopt the example to your current situation) aka RAG is.</p><p>We could use a regular Algolia &#x2F; full-text search to find examples matching the current prompt. Or we could use <em>semantic</em> meaning to quickly find similar examples. Here is one RAG implementation that I played with. It uses <a href="https://www.trychroma.com/home">ChromaDB</a> to store Markdown documents I prepared. It also can quickly find examples close to new code fragments.</p><h3><span id="prepare-documents">Prepare documents</span></h3><p>I use Markdown to store Cypress code examples and even to <a href="/blog/write-cypress-preprocessor/" title="run them as tests">run them as tests</a>. For retrieval, I extract blocks of examples from Markdown docs using <a href="https://github.com/bahmutov/cypress-markdown-preprocessor">markdown-search-scraper</a> CLI tool and store them in ChromaDB running locally.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; parseForAi &#125; <span class="keyword">from</span> <span class="string">&#x27;markdown-search-scraper/src/parse-for-ai.js&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">ChromaClient</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;chromadb&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">DefaultEmbeddingFunction</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@chroma-core/default-embed&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> modelName = <span class="string">&#x27;all-MiniLM-L6-v2&#x27;</span></span><br><span class="line"><span class="keyword">const</span> defaultEF = <span class="keyword">new</span> <span class="title class_">DefaultEmbeddingFunction</span>(modelName)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> parsed = loadedFiles</span><br><span class="line">  .<span class="title function_">map</span>(<span class="function">(<span class="params">record</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> aiRecords = <span class="title function_">parseForAi</span>(record.<span class="property">markdown</span>)</span><br><span class="line">    aiRecords.<span class="title function_">forEach</span>(<span class="function">(<span class="params">aiRecord, k</span>) =&gt;</span> &#123;</span><br><span class="line">      aiRecord.<span class="property">url</span> = record.<span class="property">url</span></span><br><span class="line">      aiRecord.<span class="property">filename</span> = record.<span class="property">filename</span></span><br><span class="line">      aiRecord.<span class="property">id</span> = record.<span class="property">filename</span> + <span class="string">&#x27;-&#x27;</span> + k</span><br><span class="line">    &#125;)</span><br><span class="line">    <span class="keyword">return</span> aiRecords</span><br><span class="line">  &#125;)</span><br><span class="line">  .<span class="title function_">flat</span>()</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;%d parsed records&#x27;</span>, parsed.<span class="property">length</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> client = <span class="keyword">new</span> <span class="title class_">ChromaClient</span>(&#123;</span><br><span class="line">  <span class="attr">ssl</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">host</span>: <span class="string">&#x27;localhost&#x27;</span>,</span><br><span class="line">  <span class="attr">port</span>: <span class="number">8000</span>,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> collection = <span class="keyword">await</span> client.<span class="title function_">getOrCreateCollection</span>(&#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&#x27;cypress-tips&#x27;</span>,</span><br><span class="line">  <span class="attr">embeddingFunction</span>: defaultEF,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> documents = parsed.<span class="title function_">map</span>(<span class="function">(<span class="params">record</span>) =&gt;</span> record.<span class="property">text</span>)</span><br><span class="line"><span class="keyword">const</span> ids = parsed.<span class="title function_">map</span>(<span class="function">(<span class="params">record</span>) =&gt;</span> record.<span class="property">id</span>)</span><br><span class="line"><span class="keyword">const</span> metadatas = parsed.<span class="title function_">map</span>(<span class="function">(<span class="params">record</span>) =&gt;</span> (&#123;</span><br><span class="line">  <span class="attr">markdown</span>: record.<span class="property">content</span>,</span><br><span class="line">&#125;))</span><br><span class="line"></span><br><span class="line"><span class="keyword">await</span> collection.<span class="title function_">add</span>(&#123;</span><br><span class="line">  documents,</span><br><span class="line">  ids,</span><br><span class="line">  metadatas,</span><br><span class="line">&#125;)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Added %d documents&#x27;</span>, documents.<span class="property">length</span>)</span><br></pre></td></tr></table></figure><ul><li>parsing Markdown for AI strips the code, but keeps the code comments</li><li>I let ChromaDB prepare an <em>embedding</em> vector from the Markdown file. It produces a long array of numbers like <code>[0.2, 0.89, ...]</code> from Markdown text.</li><li>I store the original Markdown in the DB as metadata. An alternative implementation would store a link to the original Markdown stores in another database</li><li>it takes a while to insert all 1000 Markdown examples into ChromaDB. I suggest using text hashes to only update examples that changed to save time</li></ul><h2><span id="retrieval">Retrieval</span></h2><p>Once we want to ask LLM a question, we query ChromaDB to find if it has any documents close to the query text.</p><figure class="highlight js"><figcaption><span>query.mjs</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">ChromaClient</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;chromadb&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">DefaultEmbeddingFunction</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@chroma-core/default-embed&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> modelName = <span class="string">&#x27;all-MiniLM-L6-v2&#x27;</span></span><br><span class="line"><span class="keyword">const</span> defaultEF = <span class="keyword">new</span> <span class="title class_">DefaultEmbeddingFunction</span>(modelName)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> client = <span class="keyword">new</span> <span class="title class_">ChromaClient</span>(&#123;</span><br><span class="line">  <span class="attr">ssl</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">host</span>: <span class="string">&#x27;localhost&#x27;</span>,</span><br><span class="line">  <span class="attr">port</span>: <span class="number">8000</span>,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> query = process.<span class="property">argv</span>[<span class="number">2</span>]</span><br><span class="line"><span class="keyword">if</span> (!query) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Usage: node query.mjs &quot;&lt;search query&gt;&quot;&#x27;</span>)</span><br><span class="line">  process.<span class="title function_">exit</span>(<span class="number">1</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> collection = <span class="keyword">await</span> client.<span class="title function_">getCollection</span>(&#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&#x27;cypress-tips&#x27;</span>,</span><br><span class="line">  <span class="attr">embeddingFunction</span>: defaultEF,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> results = <span class="keyword">await</span> collection.<span class="title function_">query</span>(&#123;</span><br><span class="line">  <span class="attr">queryTexts</span>: [query], <span class="comment">// Chroma will embed this for you</span></span><br><span class="line">  <span class="attr">nResults</span>: <span class="number">5</span>, <span class="comment">// how many results to return</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// console.log(results)</span></span><br><span class="line"><span class="comment">// we had only a single query result</span></span><br><span class="line"><span class="keyword">const</span> rows = results.<span class="title function_">rows</span>()[<span class="number">0</span>]</span><br><span class="line">rows.<span class="title function_">forEach</span>(<span class="function">(<span class="params">result, k</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;====&#x27;</span>)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">`Result <span class="subst">$&#123;k + <span class="number">1</span>&#125;</span> with distance <span class="subst">$&#123;result.distance&#125;</span>`</span>)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;====&#x27;</span>)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(result.<span class="property">metadata</span>.<span class="property">markdown</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Let&#39;s pretend we want to find a code example before asking LLM to implement the test</p><pre><code>$ node ./query.mjs &quot;text updated to something else after click&quot;Result 1 with distance 0.6559537In this example, we want to confirm that the text on the page changes after the user clicks the button. We do not know the initial text, just know that is changes in response to the click.&lt;div id=&quot;output&quot;&gt;Original text&lt;/div&gt;&lt;button id=&quot;change&quot;&gt;Do it&lt;/button&gt;  document    .getElementById(&#39;change&#39;)    .addEventListener(&#39;click&#39;, () =&gt; &#123;      // change the text, but do it after a random delay,      // almost like the application is loading something from the backend      setTimeout(() =&gt; &#123;        document.getElementById(&#39;output&#39;).innerText = &#39;Changed!&#39;      &#125;, 1000 + 1000 * Math.random())    &#125;)cy.get(&#39;#output&#39;)  .invoke(&#39;text&#39;)  .then((text) =&gt; &#123;    cy.get(&#39;#change&#39;).click()    cy.get(&#39;#output&#39;).should(&#39;not.have.text&#39;, text)  &#125;)Watch the explanation video Confirm The Text On The Page Changes After A Click.See also Counter incrementsResult 2 with distance 0.91608334...</code></pre><p>Nice, and notice how it &quot;decided&quot; that works like &quot;text updated&quot; are close in meaning to &quot;the text on the page changes&quot; - the match is NOT exact, but close in semantic meaning. The distance drop off between 0.65 and 0.91 is quite large, so we know the first result is much closer than the second.</p><p>Now we can insert the found example into the original LLM prompt and generate a good solution, either manually or via scripting</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> examples = <span class="keyword">await</span> <span class="title function_">RAG</span>(prompt)</span><br><span class="line"><span class="keyword">const</span> fullPrompt = <span class="string">`</span></span><br><span class="line"><span class="string">  following the examples in <span class="subst">$&#123;examples&#125;</span></span></span><br><span class="line"><span class="string">  answer the <span class="subst">$&#123;prompt&#125;</span></span></span><br><span class="line"><span class="string">`</span></span><br><span class="line"><span class="keyword">const</span> answer = <span class="keyword">await</span> <span class="title function_">myLLM</span>(fullPrompt)</span><br></pre></td></tr></table></figure><p><strong>Tip:</strong> ChromaDB can be used with other AI embeddings, see <a href="https://docs.trychroma.com/integrations/chroma-integrations">Embedding Integrations</a>.</p><h2><span id="see-also">See also</span></h2><ul><li><a href="https://cypresstips.substack.com/p/cypress-tips-june-2025">Cypress Tips June 2025 Newsletter</a></li></ul>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Imagine you ask AI&amp;#x2F;LLM how to do a particular task by describing what you want to accomplish in code comments.&lt;/p&gt;
&lt;figure class=&quot;hi
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
  </entry>
  
  <entry>
    <title>Test Tag Suggestions Using AI</title>
    <link href="https://glebbahmutov.com/blog/test-tag-suggestions-using-ai/"/>
    <id>https://glebbahmutov.com/blog/test-tag-suggestions-using-ai/</id>
    <published>2025-06-04T04:00:00.000Z</published>
    <updated>2025-06-04T15:41:52.563Z</updated>
    
    <content type="html"><![CDATA[<p>In my previous blog post <a href="/blog/pick-tests-using-ai/" title="Pick E2E Tests To Run Using AI Summaries">Pick E2E Tests To Run Using AI Summaries</a> I picked specs to run using an intermediate text summaries. In this blog post I will show a simpler and cheap way of suggesting the end-to-end test tag for each pull request.</p><blockquote class="pullquote"><p>🎁 You can find the example application source code in the repo <a href="https://github.com/bahmutov/pick-test-tag-ai">bahmutov&#x2F;pick-test-tag-ai</a>.</p></blockquote><h2><span id="the-application-and-tests">The application and tests</span></h2><p>We have a simple TodoMVC app with multiple end-to-end Cypress tests. I can see all tests and how they are tagged using the <a href="https://github.com/bahmutov/find-cypress-specs">find-cypress-specs</a> utility.</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">npx find-cypress-specs --names</span></span><br><span class="line">cypress/e2e/app-spec.js (15 tests)</span><br><span class="line">└─ TodoMVC - React</span><br><span class="line">  ├─ adds 4 todos [@smoke, @add]</span><br><span class="line">  ├─ When page is initially opened</span><br><span class="line">  │ └─ should focus on the todo input field</span><br><span class="line">  ├─ No Todos</span><br><span class="line">  │ └─ should hide #main and #footer [@smoke]</span><br><span class="line">  ├─ New Todo [@add]</span><br><span class="line">  │ ├─ should allow me to add todo items</span><br><span class="line">  │ ├─ adds items</span><br><span class="line">  │ ├─ should clear text input field when an item is added</span><br><span class="line">  │ ├─ should append new items to the bottom of the list</span><br><span class="line">  │ ├─ should trim text input</span><br><span class="line">  │ └─ should show #main and #footer when items added</span><br><span class="line">  ├─ Item</span><br><span class="line">  │ ├─ should allow me to mark items as complete</span><br><span class="line">  │ ├─ should allow me to un-mark items as complete</span><br><span class="line">  │ └─ should allow me to edit an item</span><br><span class="line">  └─ Clear completed button</span><br><span class="line">    ├─ should display the correct text</span><br><span class="line">    ├─ should remove completed items when clicked [@smoke]</span><br><span class="line">    └─ should be hidden when there are no items that are completed</span><br><span class="line"></span><br><span class="line">cypress/e2e/completed-spec.js (3 tests)</span><br><span class="line">└─ TodoMVC - React [@complete]</span><br><span class="line">  └─ Mark all as completed</span><br><span class="line">    ├─ should allow me to mark all items as completed</span><br><span class="line">    ├─ should allow me to clear the complete state of all items</span><br><span class="line">    └─ complete all checkbox should update state when items are completed / cleared [@smoke]</span><br><span class="line"></span><br><span class="line">cypress/e2e/counter-spec.js (2 tests)</span><br><span class="line">└─ TodoMVC - React [@add, @smoke]</span><br><span class="line">  └─ Counter</span><br><span class="line">    ├─ should not exist without items</span><br><span class="line">    └─ should display the current number of todo items</span><br><span class="line"></span><br><span class="line">cypress/e2e/editing-spec.js (5 tests)</span><br><span class="line">└─ TodoMVC - React [@edit]</span><br><span class="line">  └─ Editing</span><br><span class="line">    ├─ should hide other controls when editing</span><br><span class="line">    ├─ should save edits on blur [@smoke]</span><br><span class="line">    ├─ should trim entered text</span><br><span class="line">    ├─ should remove the item if an empty text string was entered</span><br><span class="line">    └─ should cancel edits on escape</span><br><span class="line"></span><br><span class="line">cypress/e2e/persistence-spec.js (1 test)</span><br><span class="line">└─ TodoMVC - React [@persistence]</span><br><span class="line">  └─ Persistence</span><br><span class="line">    └─ should persist its data [@smoke]</span><br><span class="line"></span><br><span class="line">cypress/e2e/routing-spec.js (5 tests)</span><br><span class="line">└─ TodoMVC - React [@routing]</span><br><span class="line">  └─ Routing</span><br><span class="line">    ├─ should allow me to display active items</span><br><span class="line">    ├─ should respect the back button [@smoke]</span><br><span class="line">    ├─ should allow me to display completed items</span><br><span class="line">    ├─ should allow me to display all items @smoke</span><br><span class="line">    └─ should highlight the currently applied filter</span><br><span class="line"></span><br><span class="line">found 6 specs (31 tests)</span><br></pre></td></tr></table></figure><p>We have 6 specs with 31 tests grouped into tags</p><p><img src="../images/test-tag-suggestions-using-ai/tests.png" alt="Test tags"></p><p>There are 6 different tags implemented using <a href="https://github.com/bahmutov/cy-grep">@bahmutov&#x2F;cy-grep</a> plugin.</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">$ npx find-cypress-specs --tags</span><br><span class="line">Tag           Tests</span><br><span class="line">------------  -----</span><br><span class="line">@add          9</span><br><span class="line">@complete     3</span><br><span class="line">@edit         5</span><br><span class="line">@persistence  1</span><br><span class="line">@routing      5</span><br><span class="line">@smoke        10</span><br></pre></td></tr></table></figure><p>The test tags are type-checked, see the blog post <a href="/blog/type-check-test-tags/" title="Type Check Your Test Tags">Type Check Your Test Tags</a> for details. The test tags can be found in the file <a href="https://github.com/bahmutov/pick-test-tag-ai/blob/main/cypress/support/index.d.ts">cypress&#x2F;support&#x2F;index.d.ts</a></p><figure class="highlight ts"><figcaption><span>cypress/support/index.d.ts</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * The only allowed test tags in this project</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">AllowedTag</span> =</span><br><span class="line">  | <span class="string">&#x27;@smoke&#x27;</span></span><br><span class="line">  | <span class="string">&#x27;@add&#x27;</span></span><br><span class="line">  | <span class="string">&#x27;@complete&#x27;</span></span><br><span class="line">  | <span class="string">&#x27;@edit&#x27;</span></span><br><span class="line">  | <span class="string">&#x27;@routing&#x27;</span></span><br><span class="line">  | <span class="string">&#x27;@persistence&#x27;</span></span><br></pre></td></tr></table></figure><h2><span id="picking-tests-to-run">Picking tests to run</span></h2><p>Imagine someone unfamiliar with the project&#39;s tests opens a pull request. Which tests should we run? Ideally, we would run all tests, but that might be slow. We could run a few tests across all features: the tests tagged <code>@smoke</code>. Or the author of the pull request could ask someone familiar with the tests to advise. If someone asked me &quot;which test tag is appropriate for this pull request?&quot;, I would look at the pull request title and text description to see if I can determine what user-facing changes the code change has.</p><p>Here is an empty commit and pull request:</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">bahmutov at QPW7RRQDVW  ~/git/pick-test-tag-ai on main</span><br><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">gb demo-pr</span></span><br><span class="line">Switched to a new branch &#x27;demo-pr&#x27;</span><br><span class="line">bahmutov at QPW7RRQDVW  ~/git/pick-test-tag-ai on demo-pr</span><br><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">gempty <span class="string">&quot;add localStorage wrapper&quot;</span></span></span><br><span class="line">[demo-pr cca7b87] add localStorage wrapper</span><br><span class="line">bahmutov at QPW7RRQDVW  ~/git/pick-test-tag-ai on demo-pr</span><br><span class="line"><span class="meta prompt_">$ </span><span class="language-bash">gh <span class="built_in">pr</span> create</span></span><br><span class="line">? Where should we push the &#x27;demo-pr&#x27; branch? bahmutov/pick-test-tag-ai</span><br><span class="line"></span><br><span class="line">Creating pull request for demo-pr into main in bahmutov/pick-test-tag-ai</span><br><span class="line"></span><br><span class="line">? Title (required) add localStorage wrapper</span><br><span class="line">? Body &lt;Received&gt;</span><br><span class="line">? What&#x27;s next? Submit</span><br><span class="line">remote:</span><br><span class="line">remote:</span><br><span class="line">To github.com:bahmutov/pick-test-tag-ai.git</span><br><span class="line"> * [new branch]      HEAD -&gt; demo-pr</span><br><span class="line">branch &#x27;demo-pr&#x27; set up to track &#x27;origin/demo-pr&#x27;.</span><br><span class="line">https://github.com/bahmutov/pick-test-tag-ai/pull/7</span><br></pre></td></tr></table></figure><p>I wrote the following pull request body</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Refactor loading todos on page load.</span><br></pre></td></tr></table></figure><p>Let&#39;s look at the pull request <a href="https://github.com/bahmutov/pick-test-tag-ai/pull/7">#7</a>. Notice the automatic comment <code>I recommend running tests tagged @persistence</code>. That is the AI model automatically suggesting the tag <code>@persistence</code> based on the PR title + pull request body text.</p><p><img src="../images/test-tag-suggestions-using-ai/pr7.png" alt="AI suggests running @persistence test tag"></p><p>The recommendation makes sense: we do want to run these tests, just look at the title:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">cypress/e2e/persistence-spec.js (1 test)</span><br><span class="line">└─ TodoMVC - React [@persistence]</span><br><span class="line">  └─ Persistence</span><br><span class="line">    └─ should persist its data [@smoke]</span><br></pre></td></tr></table></figure><p>Here is how it works.</p><h2><span id="the-github-actions-workflow">The GitHub Actions workflow</span></h2><p>Each time a new pull request is opened, the following workflow <a href="https://github.com/bahmutov/pick-test-tag-ai/blob/main/.github/workflows/pr-opened.yml">.github&#x2F;workflows&#x2F;pr-opened.yml</a> grabs its title and the first 1000 characters of its body text.</p><figure class="highlight yml"><figcaption><span>.github/workflows/pr-opened.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">PR</span> <span class="string">opened</span></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">pull_request:</span></span><br><span class="line">    <span class="attr">types:</span> [<span class="string">opened</span>]</span><br><span class="line"><span class="attr">permissions:</span></span><br><span class="line">  <span class="attr">contents:</span> <span class="string">write</span></span><br><span class="line">  <span class="attr">pull-requests:</span> <span class="string">write</span></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">find-test-tag:</span></span><br><span class="line">    <span class="attr">name:</span> <span class="string">Find</span> <span class="string">the</span> <span class="string">applicable</span> <span class="string">test</span> <span class="string">tag</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v4</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Install</span> <span class="string">dependencies</span> <span class="string">📦</span></span><br><span class="line">        <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">runTests:</span> <span class="literal">false</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Determine</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">find_test_tag</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          TAG=$(node ask.mjs)</span></span><br><span class="line"><span class="string">          echo &quot;TAG=$TAG&quot; &gt;&gt; $GITHUB_OUTPUT</span></span><br><span class="line"><span class="string"></span>        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">OPEN_AI_API_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_API_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">OPEN_AI_BASE_URL:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_BASE_URL</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">CODE_CHANGES:</span> <span class="string">&quot;$<span class="template-variable">&#123;&#123; github.event.pull_request.title &#125;&#125;</span>\n\n$<span class="template-variable">&#123;&#123; github.event.pull_request.body &#125;&#125;</span>&quot;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">the</span> <span class="string">determined</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;The determined test tag is: $&#123;&#123; steps.find_test_tag.outputs.TAG &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Comment</span> <span class="string">on</span> <span class="string">PR</span> <span class="string">with</span> <span class="string">the</span> <span class="string">found</span> <span class="string">tag</span> <span class="string">💬</span></span><br><span class="line">        <span class="comment"># https://github.com/peter-evans/create-or-update-comment</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">peter-evans/create-or-update-comment@v4</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GH_PERSONAL_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">repository:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.repository.full_name</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">issue-number:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.pull_request.number</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">body:</span> <span class="string">|</span></span><br><span class="line">            <span class="string">I</span> <span class="string">recommend</span> <span class="string">running</span> <span class="string">tests</span> <span class="string">tagged</span> <span class="string">**$&#123;&#123;</span> <span class="string">steps.find_test_tag.outputs.TAG</span> <span class="string">&#125;&#125;**</span></span><br></pre></td></tr></table></figure><p>The most important part is executing the <a href="https://github.com/bahmutov/pick-test-tag-ai/blob/main/ask.mjs">ask.mjs</a> script:</p><figure class="highlight yml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Determine</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">  <span class="attr">id:</span> <span class="string">find_test_tag</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">    TAG=$(node ask.mjs)</span></span><br><span class="line"><span class="string">    echo &quot;TAG=$TAG&quot; &gt;&gt; $GITHUB_OUTPUT</span></span><br><span class="line"><span class="string"></span>  <span class="attr">env:</span></span><br><span class="line">    <span class="attr">OPEN_AI_API_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_API_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">OPEN_AI_BASE_URL:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_BASE_URL</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">CODE_CHANGES:</span> <span class="string">&quot;$<span class="template-variable">&#123;&#123; github.event.pull_request.title &#125;&#125;</span>\n\n$<span class="template-variable">&#123;&#123; github.event.pull_request.body &#125;&#125;</span>&quot;</span></span><br></pre></td></tr></table></figure><p>Here is the script that uses OpenAI sdk to get one word answer.</p><figure class="highlight js"><figcaption><span>ask.mjs</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">OpenAI</span> <span class="keyword">from</span> <span class="string">&#x27;openai&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> client = <span class="keyword">new</span> <span class="title class_">OpenAI</span>(&#123;</span><br><span class="line">  <span class="attr">apiKey</span>: process.<span class="property">env</span>[<span class="string">&#x27;OPEN_AI_API_KEY&#x27;</span>],</span><br><span class="line">  <span class="attr">baseURL</span>: process.<span class="property">env</span>[<span class="string">&#x27;OPEN_AI_BASE_URL&#x27;</span>],</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">ask</span>(<span class="params">instructions, input</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> response = <span class="keyword">await</span> client.<span class="property">responses</span>.<span class="title function_">create</span>(&#123;</span><br><span class="line">    <span class="comment">// https://platform.openai.com/docs/models</span></span><br><span class="line">    <span class="attr">model</span>: <span class="string">&#x27;gpt-4.1&#x27;</span>,</span><br><span class="line">    instructions,</span><br><span class="line">    input,</span><br><span class="line">  &#125;)</span><br><span class="line">  <span class="keyword">return</span> response.<span class="property">output_text</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> instructions = <span class="string">`</span></span><br><span class="line"><span class="string">  Give the following end-to-end test tags:</span></span><br><span class="line"><span class="string">    - @smoke a few tests that go through various features of the application</span></span><br><span class="line"><span class="string">    - @add tests go through creating new todos</span></span><br><span class="line"><span class="string">    - @complete tests are creating todos and then marking then complete and incomplete</span></span><br><span class="line"><span class="string">    - @edit tests edit text for existing todos</span></span><br><span class="line"><span class="string">    - @routing tests check if the app can show screens of completed and active todos</span></span><br><span class="line"><span class="string">    - @persistence tests check how todos are saved in the browser and loaded</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  Determine which test tag is applicable to the following code changes.</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  Response with the test tag by itself and nothing else.</span></span><br><span class="line"><span class="string">  If no test tag is applicable, return &quot;@smoke&quot;.</span></span><br><span class="line"><span class="string">`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> input = process.<span class="property">env</span>[<span class="string">&#x27;CODE_CHANGES&#x27;</span>]</span><br><span class="line"><span class="keyword">if</span> (!input) &#123;</span><br><span class="line">  <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">&#x27;CODE_CHANGES environment variable is required&#x27;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// output logging into error stream</span></span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Asking OpenAI for test tags...&#x27;</span>)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">error</span>(input)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> answer = <span class="keyword">await</span> <span class="title function_">ask</span>(instructions, input)</span><br><span class="line"></span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(answer)</span><br></pre></td></tr></table></figure><p>The test tag descriptions are manual and can be expanded if needed to better describe the tests under the tag.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> instructions = <span class="string">`</span></span><br><span class="line"><span class="string">  Give the following end-to-end test tags:</span></span><br><span class="line"><span class="string">    - @smoke a few tests that go through various features of the application</span></span><br><span class="line"><span class="string">    - @add tests go through creating new todos</span></span><br><span class="line"><span class="string">    - @complete tests are creating todos and then marking then complete and incomplete</span></span><br><span class="line"><span class="string">    - @edit tests edit text for existing todos</span></span><br><span class="line"><span class="string">    - @routing tests check if the app can show screens of completed and active todos</span></span><br><span class="line"><span class="string">    - @persistence tests check how todos are saved in the browser and loaded</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  Determine which test tag is applicable to the following code changes.</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">  Response with the test tag by itself and nothing else.</span></span><br><span class="line"><span class="string">  If no test tag is applicable, return &quot;@smoke&quot;.</span></span><br><span class="line"><span class="string">`</span></span><br></pre></td></tr></table></figure><p>I tried other OpenAI models, they all worked pretty much the same. In a sense, our problem is very simple: pick the best matching text from a very limited list of available tags. LLMs seem to match the synonyms and word forms pretty well. Let&#39;s see if a pull request with the title &quot;Changed the input element&quot; matches the &quot;@add tests go through creating new todos&quot; text. Here is the pull request <a href="https://github.com/bahmutov/pick-test-tag-ai/pull/8">#8</a></p><p><img src="../images/test-tag-suggestions-using-ai/pr8.png" alt="AI suggests running @add test tag"></p><p>Nice, that is the tag for the tests that probably cover the changes to the &quot;Todo&quot; item input implementation:</p><h2><span id="test-tag-on-demand">Test tag on demand</span></h2><p>We can determine the test tag when the user asks for it, instead of computing it automatically when the pull request is opened. I can use the <a href="https://github.com/peter-evans/slash-command-dispatch">peter-evans&#x2F;slash-command-dispatch</a> action to trigger the &quot;find the test tag&quot; workflow when the user enters the <code>/ai</code> comment.</p><figure class="highlight yml"><figcaption><span>.github/workflows/dispatch.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">dispatch</span></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">issue_comment:</span></span><br><span class="line">    <span class="attr">types:</span> [<span class="string">created</span>]</span><br><span class="line"><span class="attr">permissions:</span></span><br><span class="line">  <span class="attr">contents:</span> <span class="string">write</span></span><br><span class="line">  <span class="attr">pull-requests:</span> <span class="string">write</span></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">dispatch:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Slash</span> <span class="string">Command</span> <span class="string">Dispatch</span></span><br><span class="line">        <span class="comment"># https://github.com/peter-evans/slash-command-dispatch</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">peter-evans/slash-command-dispatch@v4</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">reaction-token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GITHUB_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">permission:</span> <span class="string">none</span></span><br><span class="line">          <span class="attr">issue-type:</span> <span class="string">pull-request</span></span><br><span class="line">          <span class="attr">commands:</span> <span class="string">|</span></span><br><span class="line">            <span class="string">ai</span></span><br></pre></td></tr></table></figure><p>The only command &quot;ai&quot; in the dispatch above triggers the following workflow <a href="https://github.com/bahmutov/pick-test-tag-ai/blob/main/.github/workflows/ai-command.yml">.github&#x2F;workflows&#x2F;ai-command.yml</a></p><figure class="highlight yml"><figcaption><span>.github/workflows/ai-command.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">ai-command</span></span><br><span class="line"></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">repository_dispatch:</span></span><br><span class="line">    <span class="attr">types:</span> [<span class="string">ai-command</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">find-test-tag:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-24.04</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Dump</span> <span class="string">the</span> <span class="string">client</span> <span class="string">payload</span> <span class="string">body</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">PAYLOAD_CONTEXT:</span> <span class="string">$&#123;&#123;</span> <span class="string">toJson(github.event.client_payload.pull_request.body)</span> <span class="string">&#125;&#125;</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">echo</span> <span class="string">&quot;$PAYLOAD_CONTEXT&quot;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">repo</span> <span class="string">and</span> <span class="string">comment</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;Title of the pull request: $&#123;&#123; github.event.client_payload.pull_request.title &#125;&#125;&quot;</span></span><br><span class="line"><span class="string">          echo &quot;Repository: $&#123;&#123; github.event.client_payload.github.payload.repository.full_name &#125;&#125;&quot;</span></span><br><span class="line"><span class="string">          echo &quot;Issue number: $&#123;&#123; github.event.client_payload.github.payload.issue.number &#125;&#125;&quot;</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Checkout</span> <span class="string">🛎</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">actions/checkout@v4</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Install</span> <span class="string">dependencies</span> <span class="string">📦</span></span><br><span class="line">        <span class="comment"># https://github.com/cypress-io/github-action</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">cypress-io/github-action@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">runTests:</span> <span class="literal">false</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Determine</span> <span class="string">the</span> <span class="string">test</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">        <span class="attr">id:</span> <span class="string">find_test_tag</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          TAG=$(node ask.mjs)</span></span><br><span class="line"><span class="string">          echo &quot;TAG=$TAG&quot; &gt;&gt; $GITHUB_OUTPUT</span></span><br><span class="line"><span class="string"></span>        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">OPEN_AI_API_KEY:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_API_KEY</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">OPEN_AI_BASE_URL:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.OPEN_AI_BASE_URL</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">CODE_CHANGES:</span> <span class="string">&quot;$<span class="template-variable">&#123;&#123; github.event.client_payload.pull_request.title &#125;&#125;</span>\n\n$<span class="template-variable">&#123;&#123; github.event.client_payload.pull_request.body &#125;&#125;</span>&quot;</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">the</span> <span class="string">determined</span> <span class="string">tag</span> <span class="string">🏷️</span></span><br><span class="line">        <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">          echo &quot;The recommended test tag is: $&#123;&#123; steps.find_test_tag.outputs.TAG &#125;&#125;&quot; &gt;&gt; $GITHUB_STEP_SUMMARY</span></span><br><span class="line"><span class="string"></span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Write</span> <span class="string">tag</span> <span class="string">back</span> <span class="string">into</span> <span class="string">the</span> <span class="string">comment</span> <span class="string">💬</span></span><br><span class="line">        <span class="comment"># https://github.com/peter-evans/create-or-update-comment</span></span><br><span class="line">        <span class="attr">uses:</span> <span class="string">peter-evans/create-or-update-comment@v4</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GH_PERSONAL_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">repository:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.client_payload.github.payload.repository.full_name</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">issue-number:</span> <span class="string">$&#123;&#123;</span> <span class="string">github.event.client_payload.github.payload.issue.number</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">body:</span> <span class="string">|</span></span><br><span class="line">            <span class="attr">The recommended test tag is:</span> <span class="string">**$&#123;&#123;</span> <span class="string">steps.find_test_tag.outputs.TAG</span> <span class="string">&#125;&#125;**</span></span><br></pre></td></tr></table></figure><p><img src="../images/test-tag-suggestions-using-ai/comment.png" alt="AI posts its answer as another comment"></p><p>Nice.</p><h2><span id="cost">Cost</span></h2><p>Test tags list with a summary is small and does not change often. Pull request title plus text body are limited to 1000 characters, so are very small. Matching the PR text to the test tags should be quick and cheap AI operation. Just to see how many tokens we used</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">ask</span>(<span class="params">instructions, input</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> response = <span class="keyword">await</span> client.<span class="property">responses</span>.<span class="title function_">create</span>(&#123;</span><br><span class="line">    <span class="comment">// https://platform.openai.com/docs/models</span></span><br><span class="line">    <span class="attr">model</span>: <span class="string">&#x27;gpt-4.1&#x27;</span>,</span><br><span class="line">    instructions,</span><br><span class="line">    input,</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;response usage:&#x27;</span>)</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(response.<span class="property">usage</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> response.<span class="property">output_text</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Calling this code with &quot;changed the input element&quot; code change produces:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Asking OpenAI for test tags...</span><br><span class="line">changed the input element</span><br><span class="line">&#123;</span><br><span class="line">  input_tokens: 154,</span><br><span class="line">  input_tokens_details: &#123; audio_tokens: null, cached_tokens: 0, text_tokens: null &#125;,</span><br><span class="line">  output_tokens: 3,</span><br><span class="line">  output_tokens_details: &#123; reasoning_tokens: 0, text_tokens: null &#125;,</span><br><span class="line">  total_tokens: 157</span><br><span class="line">&#125;</span><br><span class="line">@add</span><br></pre></td></tr></table></figure><p>So we used 154 input and 3 output tokens. For the full picture, <a href="https://openai.com/api/pricing/">https://openai.com/api/pricing/</a> has <code>Input: $2.00 / 1M tokens</code> and <code>Output: $8.00 / 1M tokens</code> bringing out query cost to $0.0003</p><p>Of course, a low cost for our queries is only a part of the <a href="https://www.technologyreview.com/2025/05/20/1116287/ai-data-centers-nevada-water-reno-computing-environmental-impact/">environment costs incurred by the AI data centers</a>.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;In my previous blog post &lt;a href=&quot;/blog/pick-tests-using-ai/&quot; title=&quot;Pick E2E Tests To Run Using AI Summaries&quot;&gt;Pick E2E Tests To Run Usin
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
  </entry>
  
  <entry>
    <title>Pick E2E Tests To Run Using AI Summaries</title>
    <link href="https://glebbahmutov.com/blog/pick-tests-using-ai/"/>
    <id>https://glebbahmutov.com/blog/pick-tests-using-ai/</id>
    <published>2025-05-22T04:00:00.000Z</published>
    <updated>2025-05-22T19:50:10.823Z</updated>
    
    <content type="html"><![CDATA[<p>Imagine you have a web application. If you are reading this blog, you probably have an extensive set of end-to-end Cypress tests for this web application. Someones modifies the source code for the web app (could be changes to HTML, JavaScript, or CSS styles), and asks you &quot;which tests should I run to confirm my changes are purely a refactoring?&quot; Ideally, you would run all E2E tests, but that might be wasteful. We need to run a small subset of all tests to give the fastest feedback for the current pull request. How do you pick these tests?</p><p>In my previous blog posts, I picked E2E tests to run using different solutions:</p><ul><li>based on <a href="/blog/pick-tests-using-pull-request/" title="test tags">test tags</a>. This approach works IF you know the test tags and how the code changes maps to one or more existing test tags.</li><li>based on <a href="/blog/using-test-ids-to-pick-specs-to-run/" title="source code changes">source code changes</a> and <code>data-testid</code> attributes specifically. This works well if your code changes include such front-end modifications. What happens if your change is purely stylistics? Or touches the API layer?</li><li>based on <a href="/blog/pick-tests-by-network-calls/" title="network calls made">network calls made</a>. Nice, but does not cover style changes or more complicated scenarios.</li></ul><p>In this blog post I will show a new way of picking end-to-end tests to run: using semantic test descriptions generated using an AI model. For this demo, I used Cursor with Claude 4 models.</p><h2><span id="the-example-app">The example app</span></h2><p>I took an example &quot;Swag Store&quot; app from my course <a href="https://cypress.tips/courses/swag-store">Testing The Swag Store</a></p><p><img src="../images/pick-tests-using-ai/app.png" alt="Swag Store App"></p><p>This example app is well-tested, its source code is 100% covered by Cypress tests. You can see all tests using <a href="https://github.com/bahmutov/find-cypress-specs">find-cypress-specs</a> utility:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">$ npx find-cypress-specs --names</span><br><span class="line">cypress/e2e/cart/add-to-cart.cy.ts (1 test)</span><br><span class="line">└─ Cart</span><br><span class="line">  └─ adds items to the cart</span><br><span class="line"></span><br><span class="line">cypress/e2e/cart/cart-app-action.cy.ts (1 test)</span><br><span class="line">└─ Cart</span><br><span class="line">  └─ can be controlled via application methods</span><br><span class="line"></span><br><span class="line">cypress/e2e/cart/cart.cy.ts (3 tests)</span><br><span class="line">└─ Cart</span><br><span class="line">  ├─ shows the added items in order they were added</span><br><span class="line">  ├─ navigates to the item from the cart</span><br><span class="line">  └─ has id and quantity data- attributes</span><br><span class="line"></span><br><span class="line">cypress/e2e/cart/empty-cart.cy.ts (1 test)</span><br><span class="line">└─ Empty cart</span><br><span class="line">  └─ disables the Checkout button</span><br><span class="line"></span><br><span class="line">cypress/e2e/cart/instant-cart.cy.ts (1 test)</span><br><span class="line">└─ Cart</span><br><span class="line">  └─ shows the cart items</span><br><span class="line"></span><br><span class="line">...</span><br><span class="line"></span><br><span class="line">cypress/e2e/problem-user/sort.cy.ts (1 test)</span><br><span class="line">└─ Problem user</span><br><span class="line">  └─ cannot sort</span><br><span class="line"></span><br><span class="line">found 39 specs (69 tests, 5 pending)</span><br></pre></td></tr></table></figure><h2><span id="example-js-change">Example JS change</span></h2><p>So there are a lot of tests. Let&#39;s say the user changes 1 line in the <code>src/components/CartButton.jsx</code> source file.</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">- if (cartContents.length &gt; 0) &#123;</span></span><br><span class="line"><span class="addition">+ if (cartContents.length &gt;= 0) &#123;</span></span><br></pre></td></tr></table></figure><p>Which test should we run? We can ask someone who wrote the tests: is there a good test that covers the &quot;Cart&quot; button?</p><p><img src="../images/pick-tests-using-ai/cart-button.png" alt="Most likely affected is the Cart button"></p><p>The problem is, we have lots of cart tests. There is an entire folder of &quot;cart&quot; specs. Some of them might be testing the Cart button in the menu. Some of them might be testing the cart page.</p><p><img src="../images/pick-tests-using-ai/cart-tests.png" alt="Several spec files cover the cart components and flows"></p><p>Which test is really relevant here?</p><h2><span id="semantic-test-summaries">Semantic test summaries</span></h2><p>A human developer would start looking at the source code for each spec file, trying to understand if the test is relevant to the Cart button. Let&#39;s ask Copilot to summarize every test. Below is my prompt</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">- list all Cypress test titles including parent suite titles</span><br><span class="line">- group test titles by the Cypress spec filename</span><br><span class="line">- for each test create 3 sentence max summary using code comments, function and variable names, and log messages</span><br></pre></td></tr></table></figure><p><img src="../images/pick-tests-using-ai/generate-summaries-prompt.png" alt="Generate test summaries prompt"></p><p><strong>Important:</strong> I have attached the entire &quot;Cypress&quot; folder with spec file contents. This is a separate settings in Cursor settings &quot;Full folder contents: Enable full folder contents instead of tree outline&quot;.</p><p>Here is what AI generated:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br></pre></td><td class="code"><pre><span class="line">According to the untrusted Internet sources:</span><br><span class="line"></span><br><span class="line">I&#x27;ll analyze all the Cypress test files to extract test titles, group them by spec filename, and create summaries for each test.</span><br><span class="line"></span><br><span class="line">Search files...</span><br><span class="line">Based on the attached files, I&#x27;ll now compile a comprehensive list of all Cypress test titles grouped by spec filename with summaries. Let me analyze each file systematically:</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/add-to-cart.cy.ts**</span><br><span class="line">- **Cart &gt; adds items to the cart**: Tests adding multiple items to cart by clicking &quot;Add to cart&quot; buttons. Verifies cart badge updates correctly and buttons switch to &quot;Remove&quot; state. Confirms cart badge shows correct count after adding Sauce Labs Bike Light and Bolt T-Shirt.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/cart-app-action.cy.ts**</span><br><span class="line">- **Cart &gt; can be controlled via application methods**: Tests cart functionality using direct ShoppingCart API calls instead of UI interactions. Adds items programmatically using `ShoppingCart.addItem()` method and verifies cart contents through both API and UI elements.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/cart.cy.ts**</span><br><span class="line">- **Cart &gt; shows the added items in order they were added**: Tests adding multiple items to cart and verifying they appear in correct order on cart page. Uses InventoryData to map item names to IDs and confirms local storage persistence. Validates cart navigation and item display order.</span><br><span class="line"></span><br><span class="line">- **Cart &gt; navigates to the item from the cart**: Tests clicking item links from cart page to navigate to individual inventory item pages. Sets cart contents programmatically and verifies URL parameters include correct item ID.</span><br><span class="line"></span><br><span class="line">- **Cart &gt; has id and quantity data- attributes**: Tests cart item elements have correct data attributes for item ID and quantity. Uses cypress-map plugin to extract and validate dataset properties from DOM elements.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/empty-cart.cy.ts**</span><br><span class="line">- **Empty cart &gt; disables the Checkout button**: Tests that checkout button is disabled when cart is empty. Logs in user, visits cart page, and verifies cart badge doesn&#x27;t exist and checkout button is disabled.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/instant-cart.cy.ts**</span><br><span class="line">- **Cart &gt; shows the cart items**: Tests cart display by pre-populating localStorage with cart contents. Maps item names to IDs using InventoryData and verifies cart page shows correct items and quantities without going through add-to-cart flow.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/cart/remove-from-cart.cy.ts**</span><br><span class="line">- **Cart &gt; removes items from cart**: Tests removing items from cart using Remove buttons on cart page. Adds two items, navigates to cart, removes one item, and verifies remaining item count and cart badge updates correctly.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/checkout/checkout.cy.ts**</span><br><span class="line">- **Checkout &gt; cancels checkout**: Tests canceling checkout process returns user to cart page. Pre-populates cart with all inventory items and verifies cancel button navigation.</span><br><span class="line"></span><br><span class="line">- **Checkout &gt; requires all inputs**: Tests form validation on checkout step one requiring first name, last name, and postal code. Verifies error messages appear for missing fields and form progresses when all fields filled.</span><br><span class="line"></span><br><span class="line">- **Checkout &gt; goes through the check out pages**: Tests complete checkout flow from cart through completion. Pre-populates cart, fills checkout form, verifies summary page, and confirms cart is cleared after successful checkout.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/checkout/glitch-user.cy.ts**</span><br><span class="line">- **works for performance glitch user**: Tests checkout flow works for performance_glitch_user despite potential delays. Adds random item to cart, completes checkout process, and verifies successful completion with price validation.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/checkout/purchase.cy.ts**</span><br><span class="line">- **All users &gt; works for user persona [name]**: Skipped test suite that would test checkout flow for each user persona. Adds random item, completes checkout, and verifies successful purchase completion for different user types.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/checkout/tax.cy.ts**</span><br><span class="line">- **Checkout &gt; shows the tax within limits**: Tests tax calculation on checkout summary page falls within reasonable 5-10% range. Picks random items, calculates expected tax range, and validates displayed tax amount using regex pattern matching.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/checkout/total.cy.ts**</span><br><span class="line">- **Checkout &gt; shows the right total price**: Tests checkout summary displays correct subtotal for selected items. Picks random items, calculates expected total, and verifies summary page shows correct price.</span><br><span class="line"></span><br><span class="line">- **Checkout &gt; can purchase two bike lights**: Tests purchasing multiple quantities of same item by modifying cart quantity. Changes quantity to 2, proceeds through checkout, and verifies total price calculation for multiple items.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/images-load.cy.ts**</span><br><span class="line">- **Regular user &gt; loads every image**: Tests all inventory images load successfully by checking naturalWidth property. Iterates through all img elements and throws detailed errors for images that fail to load or lack alt attributes.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/images.cy.ts**</span><br><span class="line">- **Regular user &gt; sees unique thumbnail images**: Tests inventory images have unique URLs and don&#x27;t contain &quot;sl-404&quot; error strings. Uses cypress-map to extract src attributes and validates uniqueness.</span><br><span class="line"></span><br><span class="line">- **Regular user &gt; loads every image**: Tests all images load properly by checking naturalWidth property. Provides detailed error messages including alt text for failed image loads.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/inventory.cy.ts**</span><br><span class="line">- **Inventory &gt; has every item from the inventory**: Tests inventory page displays all items from fixture data. Loads inventory.json fixture and verifies each item&#x27;s name, description, and price appear correctly on page.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/item.cy.js**</span><br><span class="line">- **Inventory &gt; has an item with details**: Tests specific bike light item details match fixture data. Loads bike-light.json fixture and verifies item name, description, and price display correctly.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/item2.cy.js**</span><br><span class="line">- **Inventory &gt; has an item with details**: Similar to item.cy.js but uses Cypress aliases to store fixture data. Demonstrates alternative approach to accessing fixture data using `this.item` syntax.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/item3.cy.js**</span><br><span class="line">- **Inventory &gt; has an item with details**: Similar test but imports fixture data directly as ES6 module. Shows third approach to working with fixture data using import statements.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/no-isolation.cy.js**</span><br><span class="line">- **sort without test isolation &gt; by price lowest to highest**: Tests sorting inventory by price low to high without test isolation. Uses shared login state and verifies prices are in ascending order.</span><br><span class="line"></span><br><span class="line">- **sort without test isolation &gt; by price highest to highest**: Tests sorting by price high to low and verifies descending price order.</span><br><span class="line"></span><br><span class="line">- **sort without test isolation &gt; by name from A to Z**: Tests alphabetical sorting A-Z and verifies ascending name order.</span><br><span class="line"></span><br><span class="line">- **sort without test isolation &gt; by name from Z to A**: Tests reverse alphabetical sorting Z-A and verifies descending name order.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/product-ids.cy.ts**</span><br><span class="line">- **Products &gt; have unique ids**: Skipped tests that verify inventory items have unique data-itemid attributes. Shows different approaches to extracting and validating element attributes using cypress-map.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/product.cy.ts**</span><br><span class="line">- **Product &gt; shows the item**: Tests individual product page navigation and display. Clicks item link, verifies URL parameters, and confirms product details page shows correct information.</span><br><span class="line"></span><br><span class="line">- **Product &gt; shows item not found**: Tests error handling for invalid product IDs. Visits non-existent item and verifies &quot;ITEM NOT FOUND&quot; message displays.</span><br><span class="line"></span><br><span class="line">- **Product &gt; navigates by clicking the thumbnail image**: Tests navigation to product page by clicking thumbnail images instead of text links.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/sort-tests-session.cy.js**</span><br><span class="line">- **sorting session &gt; by price lowest to highest**: Tests price sorting using cy.session for login state management. Verifies ascending price order after sorting.</span><br><span class="line"></span><br><span class="line">- **sorting session &gt; by price highest to highest**: Tests descending price sorting with session management.</span><br><span class="line"></span><br><span class="line">- **sorting session &gt; by name from A to Z**: Tests alphabetical sorting with session state.</span><br><span class="line"></span><br><span class="line">- **sorting session &gt; by name from Z to A**: Tests reverse alphabetical sorting with session state.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/sort-tests.cy.ts**</span><br><span class="line">- **sorting tests &gt; by price lowest to highest**: Tests price sorting using cy.dataSession for state management. Verifies ascending price order and demonstrates advanced session handling.</span><br><span class="line"></span><br><span class="line">- **sorting tests &gt; by price highest to highest**: Tests descending price sorting with data session management.</span><br><span class="line"></span><br><span class="line">- **sorting tests &gt; by name from A to Z**: Tests alphabetical sorting with data session persistence.</span><br><span class="line"></span><br><span class="line">- **sorting tests &gt; by name from Z to A**: Tests reverse alphabetical sorting with data session.</span><br><span class="line"></span><br><span class="line">- **sorting tests &gt; does nothing for invalid sort options**: Tests handling of invalid sort options by adding custom option and verifying no sorting occurs.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/sorted.cy.js**</span><br><span class="line">- **sorted &gt; by price**: Tests basic sorting functionality by logging in and selecting sort order. Demonstrates fundamental sorting test with hardcoded credentials.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/inventory/stub-image-load.cy.ts**</span><br><span class="line">- **Product &gt; shows a broken thumbnail**: Tests image loading failure handling using cy.intercept to simulate 404 errors. Verifies broken images have zero naturalWidth and naturalHeight properties.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/call-login.cy.js**</span><br><span class="line">- **logs in by typing**: Tests standard login flow by typing credentials and clicking login button. Verifies successful navigation to inventory page.</span><br><span class="line"></span><br><span class="line">- **logs in**: Tests login using custom `change` command that bypasses React&#x27;s input handling. Demonstrates alternative input method for React applications.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/direct-attempt.cy.ts**</span><br><span class="line">- **anonymous user &gt; gets an error trying to visit the inventory page**: Tests unauthorized access protection by visiting inventory page without login. Verifies redirect to login page and error message display.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/invalid.cy.ts**</span><br><span class="line">- **Invalid user information &gt; wrong password**: Tests login failure with correct username but incorrect password. Verifies error message and URL remains on login page.</span><br><span class="line"></span><br><span class="line">- **Invalid user information &gt; wrong username and password**: Tests login failure with both incorrect username and password. Verifies appropriate error handling.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/locked-out-user.cy.ts**</span><br><span class="line">- **Locked out user &gt; shows a login error**: Tests locked out user login attempt shows appropriate error message. Verifies error display, field highlighting, and error dismissal functionality.</span><br><span class="line"></span><br><span class="line">- **Locked out user &gt; shows a login error refactored**: Same test as above but using LoginPage object methods for cleaner code organization.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/login-for-sort.cy.ts**</span><br><span class="line">- **sorting &gt; by price lowest to highest**: Tests sorting with manual session cookie management. Demonstrates custom session handling approach for maintaining login state.</span><br><span class="line"></span><br><span class="line">- **sorting &gt; by price highest to highest**: Tests descending price sorting with cookie-based session management.</span><br><span class="line"></span><br><span class="line">- **sorting &gt; by name from A to Z**: Tests alphabetical sorting with manual session persistence.</span><br><span class="line"></span><br><span class="line">- **sorting &gt; by name from Z to A**: Tests reverse alphabetical sorting with cookie management.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/login-form.cy.ts**</span><br><span class="line">- **Login form &gt; shows an error for empty username field**: Tests form validation when username field is empty. Verifies required field error message appears.</span><br><span class="line"></span><br><span class="line">- **Login form &gt; shows an error for empty password field**: Tests form validation when password field is empty after entering username. Verifies password required error.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/login/logout.cy.ts**</span><br><span class="line">- **logs out**: Tests complete logout flow including menu navigation and session termination. Verifies user cannot access protected pages after logout.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/misc/gtm-datalayer.cy.ts**</span><br><span class="line">- **dataLayer &gt; pushes cart events**: Tests Google Tag Manager dataLayer integration for cart events. Spies on dataLayer.push method and verifies addToCart events are tracked with correct item IDs.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/misc/menu.cy.ts**</span><br><span class="line">- **Menu &gt; shows all items**: Tests &quot;All Items&quot; menu navigation maintains cart state. Sets cart contents, navigates via menu, and verifies cart badge persists.</span><br><span class="line"></span><br><span class="line">- **Menu &gt; resets the shopping cart**: Tests &quot;Reset App State&quot; menu option clears cart contents. Verifies cart badge disappears and ShoppingCart API returns empty array.</span><br><span class="line"></span><br><span class="line">- **Menu &gt; goes to the About page**: Skipped test for external navigation to saucelabs.com with cross-origin handling and request interception.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/misc/saucelabs.cy.ts**</span><br><span class="line">- **visits the sauce labs site**: Skipped test for visiting external SauceLabs website with request blocking for non-SauceLabs domains.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/problem-user/checkout-step-two.cy.ts**</span><br><span class="line">- **Problem user &gt; computes the incorrect order total**: Tests problem_user&#x27;s buggy behavior where only 2 items remain in cart despite adding 4. Verifies cart contents and incomplete cart clearing after checkout.</span><br><span class="line"></span><br><span class="line">- **Problem user &gt; goes to the inventory page on cancel**: Tests cancel button navigation from checkout step two returns to inventory page.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/problem-user/checkout.cy.ts**</span><br><span class="line">- **Problem user &gt; mishandles the user information form**: Tests problem_user&#x27;s form input bugs where typed text gets corrupted. Verifies first name becomes &quot;h&quot;, last name becomes empty, and postal code remains correct.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/problem-user/inventory-images.cy.ts**</span><br><span class="line">- **Problem user &gt; shows 404 thumbnail images**: Tests problem_user sees broken images with &quot;sl-404&quot; URLs. Verifies all inventory images have same broken URL containing &quot;sl-404&quot; string.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/problem-user/remove-from-cart.cy.ts**</span><br><span class="line">- **Problem user &gt; does not remove even items from the cart**: Tests problem_user&#x27;s bug where items with even IDs cannot be removed from cart. Verifies odd ID items remove successfully but even ID items remain.</span><br><span class="line"></span><br><span class="line">## **cypress/e2e/problem-user/sort.cy.ts**</span><br><span class="line">- **Problem user &gt; cannot sort**: Tests problem_user&#x27;s inability to change sort order. Verifies sort selection doesn&#x27;t change actual item ordering on page.</span><br></pre></td></tr></table></figure><p>Pretty good summaries, no? They only need to be regenerated once in a while, since the tests don&#39;t drastically change too often.</p><p><strong>Tip:</strong> verify if the correct number of specs has been found against <code>find-cypress-specs</code> utility: I asked Copilot to confirm the list of specs:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">how many spec files did you find?</span><br><span class="line"></span><br><span class="line">...</span><br><span class="line"></span><br><span class="line">Wait, let me recount - I actually found 39 spec files total, not 35. The breakdown is:</span><br><span class="line">Cart: 6 files</span><br><span class="line">Checkout: 5 files</span><br><span class="line">Inventory: 12 files</span><br><span class="line">Login: 7 files</span><br><span class="line">Misc: 3 files</span><br><span class="line">Problem user: 5 files</span><br><span class="line">Total: 39 Cypress spec files</span><br></pre></td></tr></table></figure><p>Great, seems we found all tests.</p><h2><span id="pick-the-tests-to-run">Pick the tests to run</span></h2><p>I saved the test summaries as a local Markdown file to make it easier to use in the new chat. Now let&#39;s use the test summaries <em>only</em> to answer which test to run based on the current code changes. Here is our prompt:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">based on the test summaries only, what is the recommended spec file for the current source code change @Commit (Diff of Working State)</span><br></pre></td></tr></table></figure><p><strong>Important:</strong> the only references in this prompt are:</p><ul><li>the test summaries Markdown file</li><li>the <code>@git</code> current commit reference with the code change</li></ul><p><img src="../images/pick-tests-using-ai/local-change.png" alt="Prompt to pick the test based on the test summaries and the current code change"></p><p>Want to see the answer? Drum roll, please:</p><p><img src="../images/pick-tests-using-ai/picked-tests.png" alt="3 Spec files picked based on the code change and test summaries"></p><p>Copilot suggests we run the following 3 spec files:</p><ul><li>spec <code>cypress/e2e/cart/empty-cart.cy.ts</code> because:</li></ul><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">This test would be directly affected by your change because it currently verifies</span><br><span class="line">that &quot;cart badge doesn&#x27;t exist&quot; when the cart is empty.</span><br></pre></td></tr></table></figure><ul><li>specs <code>cypress/e2e/cart/add-to-cart.cy.ts</code> and <code>cypress/e2e/cart/remove-from-cart.cy.ts</code></li></ul><p>Let&#39;s run the <code>empty-cart.cy.ts</code> spec.</p><p><img src="../images/pick-tests-using-ai/empty-cart-test-fails.png" alt="Empty cart spec fails because of the code change"></p><p>For your information: the spec <code>add-to-cart.cy.ts</code> also fails, while the tests in <code>remove-from-cart.cy.ts</code> pass.</p><p>Great, this was the right answer; the recommended first 2 spec files caught the change in the logic introduced by the <code>if (cartContents.length &gt;= 0) &#123;</code> source line.</p><h2><span id="changed-css">Changed CSS</span></h2><p>Let&#39;s modify the styles a little bit. In the CSS file <code>src/pages/CheckOutStepOne.css</code> I will make the container much narrower:</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">- max-width: 1280px;</span></span><br><span class="line"><span class="addition">+ max-width: 180px;</span></span><br></pre></td></tr></table></figure><p>Can the user still check out? Let&#39;s find out which specs to run:</p><p><img src="../images/pick-tests-using-ai/css.png" alt="Find tests based on CSS change"></p><p>Based on test summaries and the meaning of the CSS change, the LLM recommends running the spec <code>cypress/e2e/checkout/checkout.cy.ts</code>. The spec passes, it shows that even when the screen is narrow, the user is still able to fill and submit the form.</p><p><img src="../images/pick-tests-using-ai/css-test.png" alt="Narrow checkout form still works"></p><p>Great. I hope to use this language and semantic meaning approach to picking end-to-end tests to larger pull requests.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Imagine you have a web application. If you are reading this blog, you probably have an extensive set of end-to-end Cypress tests for this
      
    
    </summary>
    
      <category term="products" scheme="https://glebbahmutov.com/blog/categories/products/"/>
    
    
      <category term="testing" scheme="https://glebbahmutov.com/blog/tags/testing/"/>
    
      <category term="cypress" scheme="https://glebbahmutov.com/blog/tags/cypress/"/>
    
      <category term="advice" scheme="https://glebbahmutov.com/blog/tags/advice/"/>
    
      <category term="ai" scheme="https://glebbahmutov.com/blog/tags/ai/"/>
    
  </entry>
  
</feed>
