<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: On Pounch Man</title>
    <description>The latest articles on DEV Community by On Pounch Man (@liuxinyea).</description>
    <link>https://dev.to/liuxinyea</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F906782%2F5673f832-5fd3-4873-a012-9238eec8a533.jpeg</url>
      <title>DEV Community: On Pounch Man</title>
      <link>https://dev.to/liuxinyea</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/liuxinyea"/>
    <language>en</language>
    <item>
      <title>My Landing Page Has 50 Tests and None of Them Need a Browser</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Sat, 10 Oct 2026 06:05:59 +0000</pubDate>
      <link>https://dev.to/liuxinyea/my-landing-page-has-50-tests-and-none-of-them-need-a-browser-55pg</link>
      <guid>https://dev.to/liuxinyea/my-landing-page-has-50-tests-and-none-of-them-need-a-browser-55pg</guid>
      <description>&lt;p&gt;The landing page said the tool helps you prepare messages. Two edits later it said the tool reliably sends them. One more and it promised reliable delivery.&lt;/p&gt;

&lt;p&gt;No check failed. No reviewer objected. The product simply started claiming something the code cannot guarantee, and nothing in the pipeline had an opinion about it.&lt;/p&gt;

&lt;p&gt;That is the failure mode I actually have. Not a crash. A sentence that drifts away from what the code does, one reasonable commit at a time.&lt;/p&gt;

&lt;p&gt;The suite that grew out of that worry is 50 tests across 14 files, and it finishes in 568ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tap"&gt;&lt;code&gt;$ node --test "tests/**/*.test.mjs"
&lt;span class="c"&gt;# tests 50
# pass 50
# fail 0
# duration_ms 568.668334
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No browser. No dev server. No build step. Here is what it covers, and where I think it is the wrong tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser APIs are injectable if you stop reaching for globals
&lt;/h2&gt;

&lt;p&gt;Everything about storage in this app goes through one factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createWorkspaceStorage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;indexedDB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;uuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those defaults are the entire reason the storage layer is testable in Node. In tests I hand it &lt;code&gt;fake-indexeddb&lt;/code&gt;, a Map pretending to be localStorage, and a frozen clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IDBFactory&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;clock&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createWorkspaceStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Map is returned rather than hidden so a test can assert on raw keys, which is what makes the migration tests further down possible. &lt;code&gt;sql.js&lt;/code&gt; is WebAssembly, so the SQLite session parser runs in Node with no shims at all.&lt;/p&gt;

&lt;p&gt;One caveat: no DOM. Anything that only exists in a rendered component is not covered here and I am not pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the shape of prose, never the sentences
&lt;/h2&gt;

&lt;p&gt;I do not assert that my copy is good. I assert the properties that survive a rewrite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app/pages/index.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/class="faq-item"&amp;gt;&amp;lt;h3&amp;gt;&amp;lt;button/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;th scope="col"&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;th scope="row"&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;cite&amp;gt;&amp;lt;a/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;time datetime="2026-10-08"&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things are pinned here and none of them is a word choice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;h3&amp;gt;&amp;lt;button&amp;gt;&lt;/code&gt; means the FAQ is a real disclosure widget. A &lt;code&gt;div&lt;/code&gt; with a click handler looks identical on screen and is invisible to a keyboard, a screen reader, and most crawlers. That mistake is easy to make and easy to miss in review.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scope&lt;/code&gt; on the headers means the comparison table parses as a table.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;cite&amp;gt;&lt;/code&gt; next to an &lt;code&gt;https://telegram.org/faq_spam&lt;/code&gt; link means claims about Telegram's own policy point at Telegram rather than at a blog post that paraphrased it. One test is stricter than that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://telegram.org/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every reference in every article has to be a primary source. Wanting to cite a third party therefore requires changing the test, which is the conversation I want to be forced into.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;time datetime="2026-10-08"&amp;gt;&lt;/code&gt; means the page carries the date it was last verified. That date decides whether the page is trustworthy in a year, and it is also the easiest thing in the world to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bilingual parity is a contract you can execute
&lt;/h2&gt;

&lt;p&gt;English is the source language. Chinese gets written after, usually days later, sometimes without me rereading the English.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverAlt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I cannot proofread Chinese at the speed I write English, so I stopped verifying words and started verifying shape. Section counts match. Every article has a table and a list, which stops one language from getting an article that reads as a wall of text. Cover alt text is long enough to describe something.&lt;/p&gt;

&lt;p&gt;There is also a test whose only job is keeping one bad phrase from coming back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telegram message send,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ugly. It has been in the repo for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assert document order, not just presence
&lt;/h2&gt;

&lt;p&gt;The homepage makes an argument in sequence. What the tool is, how a campaign runs, what to do when it breaks, then the questions people ask before they trust it. Reorder those blocks and the page still renders, still looks fine, and still passes any test that only checks for presence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;workflow-section&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;FeatureStories&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;FeatureStories&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class="faq section&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordering assertions are cheap and they encode intent that no linter and no type system will ever know about.&lt;/p&gt;

&lt;p&gt;I also count data entries with regex, which I recommend with a caveat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/sections: &lt;/span&gt;&lt;span class="se"&gt;\[\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind: 'compose'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line says each of three stories appears exactly twice, once per language. It works, and it will break the day someone reformats that file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The analytics boundary is the most tested thing in the repo
&lt;/h2&gt;

&lt;p&gt;Seven of the fifty tests exist because analytics is the only path by which user data can leave the machine.&lt;/p&gt;

&lt;p&gt;The whole boundary is one function and one allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;account_login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password_required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;session_import&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;campaign_complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeAnalyticsEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Event names that are not in the schema are dropped entirely, and so is every parameter that is not listed for that event. A parameter whose value is not one of the enumerated strings never makes it out either, which is what makes free text structurally unable to be recorded.&lt;/p&gt;

&lt;p&gt;The test that carries the weight asserts on the entire output rather than the absence of one field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;safeAnalyticsEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Written this way it fails if anyone adds a field later, including a field I might approve of. Blocklists leak things nobody thought of. A snapshot of the allowed output does not.&lt;/p&gt;

&lt;p&gt;The same shape applies to page paths and clicks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;analyticsPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/zh/guide?phone=123#code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// { page_path: '/zh/guide', ... }&lt;/span&gt;
&lt;span class="nf"&gt;analyticsPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/user/private-phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;// { page_path: '/404', ... }&lt;/span&gt;
&lt;span class="nf"&gt;classifyAnalyticsLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;javascript:alert(1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// null&lt;/span&gt;
&lt;span class="nf"&gt;classifyAnalyticsLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://t.me/private-user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A phone number that ends up in a URL cannot reach the analytics provider, because the path is matched against a fixed list and everything else becomes &lt;code&gt;/404&lt;/code&gt;. Click events never carry an href. They carry a destination label assigned by code, and a link whose target is not one of the known ones reports nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that lints my future code
&lt;/h2&gt;

&lt;p&gt;Unit tests check code that exists. The failure I was worried about lives in code I have not written yet, at a call site that goes around the safe function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../app/components/OnlineWorkspace.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/track&lt;/span&gt;&lt;span class="se"&gt;\([^&lt;/span&gt;&lt;span class="sr"&gt;;&lt;/span&gt;&lt;span class="se"&gt;\n]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/phone&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;value|message&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;value|file&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;name|account&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;id|account&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;phone|cause&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;message/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks every &lt;code&gt;track()&lt;/code&gt; call and fails if any of them mentions message content, a phone number, an account id, a filename, or a raw error message. Crude regex, and it has already caught one call site I wrote on autopilot.&lt;/p&gt;

&lt;p&gt;The analytics plugin gets checked for the settings that are easy to lose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/send_page_view: false/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/allow_google_signals: false/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/ad_user_data: 'denied'/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document.referrer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;send_page_view: false&lt;/code&gt; is deliberate. This is a client-routed app, so the built-in page view fires at the wrong moment and I fire it manually after navigation.&lt;/p&gt;

&lt;p&gt;One assertion exists purely because of a race I hit while building the consent flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage.getItem(storageKey)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;consentReady.value = true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Saved consent has to be read from storage before the app declares consent resolved. Get that order wrong and a returning visitor either sees the banner again, or gets measured before their stored choice is applied. No type catches this. Now a test does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration tests where the interesting part is failure
&lt;/h2&gt;

&lt;p&gt;Moving accounts and logs out of localStorage produced the three tests I would have written last if I had written them from the code. I wrote them from the failure modes instead.&lt;/p&gt;

&lt;p&gt;The rule is commit first, delete second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// initialize() writes to IndexedDB, then removes the legacy keys.&lt;/span&gt;
&lt;span class="c1"&gt;// Any failed write means nothing is removed and everything rolls back.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// both legacy keys still present&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;legacy.logs.v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// clean retry&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the case where the delete never succeeds. I replace &lt;code&gt;removeItem&lt;/code&gt; with something that throws and run initialization twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removeItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Storage denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// no duplicate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A migration that is not idempotent duplicates every record on each retry, so the user opens the page and finds their log twice. That bug ships quietly.&lt;/p&gt;

&lt;p&gt;Third, boundaries, using the injected clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;retainLogs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LOG_MAX_AGE&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LOG_MAX_AGE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly thirty days old is kept. One millisecond older is dropped. A record with an unparseable timestamp is dropped rather than sorted into some unpredictable position. Testing that without an injectable clock means waiting a month.&lt;/p&gt;

&lt;p&gt;There is an atomicity test as well: saving an account that is missing its id rejects and leaves the previously stored accounts untouched. Accounts hold sessions, so a partial write there is not cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security regressions deserve names
&lt;/h2&gt;

&lt;p&gt;The session importer reads a SQLite file the user downloaded from somewhere else, and that file contains the Telegram server address to connect to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATE TABLE sessions (dc_id INTEGER, server_address TEXT, port INTEGER, auth_key BLOB)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO sessions VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attacker.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseSessionBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;serverAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;149.154.167.51&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test builds a real database, plants a hostile endpoint in it, and asserts the parser throws the address away and dials the official one. If someone later simplifies the parser into trusting the file, this test fails and its name explains why.&lt;/p&gt;

&lt;p&gt;The key in that test is a synthetic 256-byte array and the test file says so in a comment. A test that ships a real credential into a CI log is its own kind of bug.&lt;/p&gt;

&lt;p&gt;The credential masking pair gets similar treatment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskTelegramApiHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;restoreTelegramApiHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masked&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;masked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;maskTelegramApiHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// fresh mask every call&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;restoreTelegramApiHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round trip works, the plaintext never appears as a substring of the output, and garbage input returns an empty string instead of throwing or echoing back what it received.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;String assertions against source files are brittle, and I want to name the specific places mine are.&lt;/p&gt;

&lt;p&gt;This one slices a Vue component between two markers and asserts where a log reset lands relative to the calls around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;async function sendCampaign()&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onMounted(async&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;await storage.clearLogs(); logs.value = [];&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;await client.connect()&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;await client.sendMessage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It breaks on a rename. It is also the only test I have that catches a bug class no unit test can see, because the bug is not in the storage layer and not in the send loop. It is in the order they are wired together, and that order is what a user experiences when the previous campaign's log appears at the top of the new one.&lt;/p&gt;

&lt;p&gt;Same story for the &lt;code&gt;indexOf&lt;/code&gt; ordering checks and for counting entries with regex. Tests that read &lt;code&gt;.vue&lt;/code&gt; files can pass while the page is broken in a real browser. I accept that trade, because the failure I need to catch here is content that slowly stops being true, and that failure is invisible to a unit test no matter how well the layer underneath is factored.&lt;/p&gt;

&lt;p&gt;My rule when writing these: assert what a reader, a crawler, or a keyboard can observe. Markup semantics, document order, cross-language parity, timestamp validity, and where user data stops moving. Skip anything I plan to refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this code lives
&lt;/h2&gt;

&lt;p&gt;Every assertion above belongs to TGSender, a free Telegram bulk message sender that runs entirely in the browser: &lt;a href="https://tgsender.wadesk.io" rel="noopener noreferrer"&gt;TGSender&lt;/a&gt;. If you would rather not keep a tab open, there is a &lt;a href="https://chromewebstore.google.com/detail/djkljakafjipbeiajacdffaobdnmafkf" rel="noopener noreferrer"&gt;Chrome extension&lt;/a&gt; that runs the same code in the background.&lt;/p&gt;

&lt;p&gt;One claim I am not going to write, and the suite is the reason: free does not mean unlimited sending. Telegram's own rate limits, permissions, and privacy settings all still apply, and the 30-second gap between messages is a number this tool chose rather than one Telegram endorses. Every sentence in that paragraph has something in the suite holding it up, so if I ever drift, something goes red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line I keep coming back to
&lt;/h2&gt;

&lt;p&gt;If a claim is load-bearing for user safety, legal exposure, or accessibility, promote it from prose into a failing test.&lt;/p&gt;

&lt;p&gt;Prose rots on its own. A failing test rots loudly.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>nuxt</category>
      <category>telegram</category>
    </item>
    <item>
      <title>I Built an AI Tarot Demo in Two Hours. Shipping the Product Was a Different Job.</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Fri, 18 Sep 2026 09:59:28 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-an-ai-tarot-demo-in-two-hours-shipping-the-product-was-a-different-job-23ck</link>
      <guid>https://dev.to/liuxinyea/i-built-an-ai-tarot-demo-in-two-hours-shipping-the-product-was-a-different-job-23ck</guid>
      <description>&lt;p&gt;My wife was interested in tarot cards, and I was a frontend developer looking for an excuse to build something. I put together a page with a deck, a card-flipping animation, and an AI-generated reading.&lt;/p&gt;

&lt;p&gt;With AI assistance, the basic flow worked in about two hours.&lt;/p&gt;

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

&lt;p&gt;I thought authentication and payments would get it ready to launch. Then the questions started: What happens when someone switches devices? What if a paid reading stops halfway through? Can I change a guided exercise without changing the meaning of someone’s saved answers?&lt;/p&gt;

&lt;p&gt;Eventually, another question became harder than any of those: how do I get people to use it?&lt;/p&gt;

&lt;p&gt;The project is now &lt;a href="https://tarot.auraflame.tech/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=demo_to_product" rel="noopener noreferrer"&gt;Mystic Journey&lt;/a&gt;. It includes a daily card ritual, guided explorations, a personal journal, and an optional companion board.&lt;/p&gt;

&lt;p&gt;I’m still looking for early users. This is a development retrospective—and, openly, an invitation to try what I’ve built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I changed the product
&lt;/h2&gt;

&lt;p&gt;The original experience was straightforward: ask a question, draw cards, read the interpretation, leave.&lt;/p&gt;

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

&lt;p&gt;But a general-purpose AI can already explain tarot cards. A nicer animation wasn’t enough to explain why someone should return to my website.&lt;/p&gt;

&lt;p&gt;I started thinking about the experience around the reading. Writing in an empty journal can be surprisingly difficult. A card, a specific question, or a few choices might make it easier to begin reflecting.&lt;/p&gt;

&lt;p&gt;That became the daily ritual: choose your mood, reveal your daily card, read a reflection prompt, and optionally write a few words. The aim is to make a small check-in easy, even when you don’t have a major question to ask.&lt;/p&gt;

&lt;p&gt;For people who want to go further, I added six guided themes covering space, boundaries, change, confidence, connection, and direction. Each has five short chapters: notice the situation, consider another perspective, choose a small action, identify obstacles, and find support.&lt;/p&gt;

&lt;p&gt;These explorations use curated branches rather than generating every step with AI. Earlier choices influence later options. Personal notes are optional and don’t automatically trigger a model call.&lt;/p&gt;

&lt;p&gt;This gives me content I can review and improve consistently. Open-ended AI readings still have a place, but they don’t need to power every interaction.&lt;/p&gt;

&lt;p&gt;Completed rituals, explorations, and explicitly saved readings go into a personal Journey timeline. The intention is to give people something worth revisiting: their own thoughts and choices, not just a list of cards.&lt;/p&gt;

&lt;p&gt;I also added an opt-in weekly companion board. It shows names, avatars, and activity points, but not private reflections. Points are separate from purchases and don’t depend on someone’s mood or how much they write.&lt;/p&gt;

&lt;p&gt;There’s a tension here: even a gentle leaderboard can create comparison. Whether it feels supportive is something I need users to tell me. These are product hypotheses, not proven retention improvements.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small stack, with more state than I expected
&lt;/h2&gt;

&lt;p&gt;The stack is Nuxt 3 for the statically generated frontend, a separate Fastify API, SQLite, and DeepSeek for generated readings. Nginx routes API requests to the backend.&lt;/p&gt;

&lt;p&gt;For the current scale, I want something I can deploy and troubleshoot on my own. The difficult work has mostly been in the behavior of the application, not the number of services.&lt;/p&gt;

&lt;p&gt;A daily card becomes account data once it connects to reflections, history, and rewards. Someone opening the site on their phone should see the same record they started on their laptop. Completion and rewards need consistent server-side state.&lt;/p&gt;

&lt;p&gt;Disabling a button helps the interface, but it doesn’t prevent retries or requests from another tab.&lt;/p&gt;

&lt;p&gt;Guided exploration saves have a similar problem. If a laptop has already saved the next chapter while a phone still has an older revision, accepting the phone’s write could overwrite newer progress.&lt;/p&gt;

&lt;p&gt;Relevant save requests carry a revision. A stale write gets a conflict response, and the frontend keeps the draft while offering to reload saved progress. Identical committed retries return the existing result.&lt;/p&gt;

&lt;p&gt;These are easy details to overlook when you only test one browser tab on a reliable connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The content needed versioning, too
&lt;/h2&gt;

&lt;p&gt;One of the more interesting problems came from changing the exploration itself.&lt;/p&gt;

&lt;p&gt;Suppose a saved answer says the user selected option &lt;code&gt;1&lt;/code&gt;. If I reorder the options next week, that stored value can appear to mean something different.&lt;/p&gt;

&lt;p&gt;This is a simplified example of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version 1, option 1: Take time to reflect privately
Version 2, option 1: Talk to someone you trust
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number didn’t change. The user’s apparent answer did.&lt;/p&gt;

&lt;p&gt;An exploration therefore retains the content version it started with. Old option meanings need to remain stable, and the live chapter and saved recap need to interpret answers through the same branch logic.&lt;/p&gt;

&lt;p&gt;Once copy gives meaning to stored answers, editing it is also a data-compatibility decision.&lt;/p&gt;

&lt;p&gt;Weekly reflections brought another state problem. A generation request can time out, a retry can start, and the original request can finish late. Without a guard, that older result could overwrite the newer one.&lt;/p&gt;

&lt;p&gt;The implementation uses a generation state, an expiring lease, and a version condition when writing the result. A short database transaction protects state changes; it doesn’t stay open while waiting for the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  A paid AI request has more than two outcomes
&lt;/h2&gt;

&lt;p&gt;The demo treated generation as success or failure. Streaming makes that less tidy: a request can return some text, stall, lose its client connection, or finish without usable content.&lt;/p&gt;

&lt;p&gt;Deep readings use server-sent events. The server applies a timeout and attempts to cancel the upstream request if the client disconnects early. Only a complete successful result goes into the result cache.&lt;/p&gt;

&lt;p&gt;If generation fails after credits have been deducted, the error path restores those in-app credits and provides a local card interpretation. Restoring credits is separate from refunding a payment through the payment provider.&lt;/p&gt;

&lt;p&gt;Weekly reflections also have a model-call budget and a local fallback. A free feature still needs a cost boundary.&lt;/p&gt;

&lt;p&gt;This isn’t a complete recovery system. For example, a process crash after a deduction cannot be recovered by a &lt;code&gt;catch&lt;/code&gt; block in that same process. Durable job state and reconciliation remain areas to improve.&lt;/p&gt;

&lt;p&gt;That distinction matters: having an error handler is not the same as having verified recovery from every failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security extends beyond the prompt
&lt;/h2&gt;

&lt;p&gt;The model generates text. It doesn’t decide account permissions, payment status, or credit balances, and it has no database or payment tools.&lt;/p&gt;

&lt;p&gt;That limits the consequences of a manipulated response, but it doesn’t solve prompt injection. User input can still interfere with the intended task, and model output needs to be treated as untrusted content when it reaches the UI.&lt;/p&gt;

&lt;p&gt;The service currently has request rate limiting, body-size limits, and account and credit checks before paid generation. These are basic controls, not a claim of comprehensive protection. IP-based limits have limitations, and input handling and output rendering require their own review.&lt;/p&gt;

&lt;p&gt;For accounts, passwords use salted hashes, the database stores hashes of session tokens, and session cookies use HttpOnly and SameSite settings, with Secure enabled for production. Verification codes expire and have attempt limits.&lt;/p&gt;

&lt;p&gt;Payments have a separate trust boundary. The backend confirms payment status with the provider and verifies webhook signatures. Order-status requests check ownership. Since polling and a webhook can both confirm the same payment, crediting an order must be idempotent.&lt;/p&gt;

&lt;p&gt;Privacy also affects ordinary feature decisions. The feedback form doesn’t automatically attach someone’s journal entries. Analytics can record that a reflection was saved without collecting its text.&lt;/p&gt;

&lt;p&gt;I still have security work to do. Keeping track of what each component can access—and where personal content goes—has been more useful than treating safety as a few lines in a system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance and localization added their own work
&lt;/h2&gt;

&lt;p&gt;Static generation lets me serve public pages separately from account APIs, but it doesn’t automatically make the experience fast. Images, animations, authentication checks, and API latency still matter on a phone.&lt;/p&gt;

&lt;p&gt;The Journey timeline is paginated, details load on demand, and uploaded avatars are cropped and compressed to WebP in the browser. Streaming also needs the proxy to cooperate: response buffering can hide the incremental output the server is sending.&lt;/p&gt;

&lt;p&gt;The interface supports eight languages, while the card data is primarily English and Chinese, with English fallback elsewhere. Those are different levels of localization, and I shouldn’t present them as equivalent.&lt;/p&gt;

&lt;p&gt;A recent bug was a good reminder of the extra surface area: an email placeholder contained a literal &lt;code&gt;@&lt;/code&gt;, which the i18n message compiler interpreted as special syntax. The JSON was valid, but the page failed. A JSON parse check alone couldn’t catch it.&lt;/p&gt;

&lt;p&gt;Even a small copy change needs the right kind of validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping didn’t bring users automatically
&lt;/h2&gt;

&lt;p&gt;I’ve shared the product on Solo, 出海栈, 小众软件, and Indie Hackers. That has given it places to be discovered, but I don’t yet have enough evidence to call any channel a repeatable source of users.&lt;/p&gt;

&lt;p&gt;I’m also learning to separate people who enjoy a development story from people who want the product. Developers may find concurrency handling interesting. A potential user wants to know what they can do, how much effort it takes, and whether their writing stays private.&lt;/p&gt;

&lt;p&gt;There’s overlap, but a well-read technical post doesn’t prove demand.&lt;/p&gt;

&lt;p&gt;My next experiments are technical retrospectives like this one, short product demonstrations, and content built around concrete reflection scenarios. Search is another ongoing task: a readable sitemap and a renderable page are necessary pieces, but I still need public content that answers something people actually look for.&lt;/p&gt;

&lt;p&gt;What I want to measure is the path after a visit: does someone complete a first ritual, continue to another exploration chapter, or return a few days later? Knowing where that stops should help me choose between improving the entry flow, the content, or the acquisition channel.&lt;/p&gt;

&lt;p&gt;Writing more code is the comfortable option. It gives me a visible result. Asking someone to try the product can end with “I’ll take a look” and nothing else. I’m trying not to mistake the comfort of development for evidence that another feature is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project is now
&lt;/h2&gt;

&lt;p&gt;Mystic Journey is still early. I’m looking for a small group of people willing to try it and tell me what feels useful, confusing, or unnecessary.&lt;/p&gt;

&lt;p&gt;The daily ritual and guided explorations are free; deeper AI readings use credits. If tarot is unfamiliar, you can start with an exploration theme that fits something you’re thinking about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tarot.auraflame.tech/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=demo_to_product" rel="noopener noreferrer"&gt;Try Mystic Journey&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Specific feedback would help most: where you got stuck, what didn’t make sense, or where you stopped wanting to continue. There’s a feedback form in the app, and comments here are welcome too.&lt;/p&gt;

&lt;p&gt;If you’ve taken a side project beyond the demo stage, I’d also like to hear how you found your first users who came back.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sideprojects</category>
      <category>nuxt</category>
    </item>
    <item>
      <title>I Built TGDown, a Chrome Extension for Downloading Telegram Web Media</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Thu, 17 Sep 2026 03:17:08 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-tgdown-a-chrome-extension-for-downloading-telegram-web-media-1j55</link>
      <guid>https://dev.to/liuxinyea/i-built-tgdown-a-chrome-extension-for-downloading-telegram-web-media-1j55</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;TGDown&lt;/strong&gt;, a Chrome extension that adds download controls for images and videos in Telegram Web.&lt;/p&gt;

&lt;p&gt;The original product goal was simple: when users browse a Telegram chat, channel, or search result, they should be able to save media without repeatedly opening the viewer, fighting the context menu, or handling every item one by one.&lt;/p&gt;

&lt;p&gt;The implementation was less simple.&lt;/p&gt;

&lt;p&gt;Telegram Web is a large, fast-moving single-page application. An extension needs to add UI without breaking scrolling, media interactions, theme behavior, or Telegram's own event handling. This post is about the architecture behind TGDown — and a dark-mode bug that forced me to rethink how its in-page UI was mounted.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What TGDown does
&lt;/h2&gt;

&lt;p&gt;TGDown supports both Telegram Web interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;web.telegram.org/a/*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web.telegram.org/k/*&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-click image and video downloads inside chats&lt;/li&gt;
&lt;li&gt;Batch downloads from Telegram search results&lt;/li&gt;
&lt;li&gt;A popup panel that lists detected media&lt;/li&gt;
&lt;li&gt;Warnings for very large or long videos before a browser download starts&lt;/li&gt;
&lt;li&gt;No separate TGDown account or subscription&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  The extension architecture
&lt;/h2&gt;

&lt;p&gt;TGDown is a Manifest V3 extension built with TypeScript and Vue.&lt;/p&gt;

&lt;p&gt;There are three execution contexts involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Content script&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Runs alongside Telegram Web, watches the DOM for supported images and videos, and mounts download controls.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Page-context script&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Some downloads need Telegram Web's existing authenticated browser session. Since a content script is isolated from the page's JavaScript context, TGDown injects a small page-context script and communicates with it through DOM events.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extension pages&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The toolbar popup is a Vue application. It lists media detected while browsing and can trigger a download from the active Telegram tab.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This separation keeps Telegram Web responsible for its own session, while the extension stays isolated from the application's internal code as much as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dark-mode white-screen bug
&lt;/h2&gt;

&lt;p&gt;One of the most interesting bugs appeared only in Telegram's dark theme.&lt;/p&gt;

&lt;p&gt;My first approach for modal-style UI used a full-screen &lt;code&gt;iframe&lt;/code&gt;. The iframe had a transparent background, which seemed like a safe way to isolate extension styles from Telegram's styles.&lt;/p&gt;

&lt;p&gt;In practice, it was not safe.&lt;/p&gt;

&lt;p&gt;On a dark Telegram page, users would occasionally see the entire page turn white. It looked as if Telegram had failed to load, but the problem was the extension UI.&lt;/p&gt;

&lt;p&gt;The sequence was roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A full-screen iframe was inserted above Telegram.&lt;/li&gt;
&lt;li&gt;The browser created the iframe document.&lt;/li&gt;
&lt;li&gt;Before the iframe application had initialized — or when initialization failed — the iframe document rendered the browser's default page canvas.&lt;/li&gt;
&lt;li&gt;That canvas was white, even though the iframe element itself was configured as transparent.&lt;/li&gt;
&lt;li&gt;Because the iframe covered the viewport, it hid Telegram completely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bug was much easier to notice in dark mode because a white flash or white overlay has maximum contrast against Telegram's dark UI. In light mode, the same failure could be almost invisible.&lt;/p&gt;

&lt;p&gt;The key lesson was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A transparent iframe element does not guarantee a transparent iframe document.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: Shadow DOM instead of a full-screen iframe
&lt;/h2&gt;

&lt;p&gt;I removed the full-screen iframe from the Telegram page UI path.&lt;/p&gt;

&lt;p&gt;TGDown now mounts its in-page controls in a Shadow DOM host. This preserves CSS isolation while avoiding a separate document with its own default rendering surface.&lt;/p&gt;

&lt;p&gt;The host is also designed not to interfere with Telegram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.download-button&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.modal&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.toast&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host itself ignores pointer events. Only actual TGDown controls opt back in, so Telegram remains clickable and scrollable everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the integration conservative
&lt;/h2&gt;

&lt;p&gt;Telegram Web has its own click handling, overlays, media viewer, and virtualized message list. A browser extension can easily become too aggressive: global CSS overrides, broad event listeners, or page-wide &lt;code&gt;z-index&lt;/code&gt; layers may fix one download issue while breaking unrelated Telegram behavior.&lt;/p&gt;

&lt;p&gt;TGDown takes a narrower approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only targets supported media elements and its own controls.&lt;/li&gt;
&lt;li&gt;It avoids replacing Telegram's global objects or &lt;code&gt;fetch&lt;/code&gt; implementation.&lt;/li&gt;
&lt;li&gt;It waits until Telegram has completed its initial page load before injecting behavior that depends on the page context.&lt;/li&gt;
&lt;li&gt;It uses Shadow DOM rather than global CSS to isolate extension UI.&lt;/li&gt;
&lt;li&gt;It keeps the UI non-interactive by default, enabling pointer events only for visible TGDown controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Telegram Web can still change its DOM structure, but keeping the integration narrow makes failures more contained and easier to diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser downloads still have limits
&lt;/h2&gt;

&lt;p&gt;For regular images and videos, browser downloads work well. Larger media is different: browser memory, network stability, and the way Telegram Web exposes media URLs can affect reliability.&lt;/p&gt;

&lt;p&gt;TGDown warns before especially large or long downloads, but leaves the final decision to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try TGDown
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tgdown.wadesk.io/" rel="noopener noreferrer"&gt;TGDown official website&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://chromewebstore.google.com/detail/pobheilhkabfglljggbjlcphpcmdmdbn" rel="noopener noreferrer"&gt;Install TGDown from the Chrome Web Store&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Telegram Web changes and TGDown stops detecting a specific kind of media, feedback is welcome. The most helpful report includes the Telegram Web version (&lt;code&gt;A&lt;/code&gt; or &lt;code&gt;K&lt;/code&gt;), media type, and reproducible steps.&lt;/p&gt;

</description>
      <category>browserextensions</category>
      <category>typescript</category>
      <category>vue</category>
      <category>node</category>
    </item>
    <item>
      <title>I Built TabScribe: A Free Chrome Extension to Copy All Browser Tabs as Markdown (Under 100KB, 12 Languages)</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Sun, 07 Jun 2026 11:47:17 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-tabscribe-a-free-chrome-extension-to-copy-all-browser-tabs-as-markdown-under-100kb-12-1bok</link>
      <guid>https://dev.to/liuxinyea/i-built-tabscribe-a-free-chrome-extension-to-copy-all-browser-tabs-as-markdown-under-100kb-12-1bok</guid>
      <description>&lt;h1&gt;
  
  
  Why I Built TabScribe
&lt;/h1&gt;

&lt;p&gt;After yet another research session where I found myself manually copying 20+ browser tab URLs one by one into my Obsidian notes, I decided enough was enough. I needed a tool that could copy all my tabs at once — in Markdown format.&lt;/p&gt;

&lt;p&gt;I searched the Chrome Web Store. The existing options were either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Too bloated (OneTab at 2M+ users, but does way more than I need)&lt;/li&gt;
&lt;li&gt;Only supported plain text URL lists (Copy All URLs)&lt;/li&gt;
&lt;li&gt;Abandoned for years (the old TabCopy extension)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;TabScribe&lt;/strong&gt; over a couple of weekends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TabScribe Does
&lt;/h2&gt;

&lt;p&gt;One click → all your tabs land in your clipboard in your chosen format:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Markdown&lt;/strong&gt; &lt;code&gt;[title](url)&lt;/code&gt; — perfect for Obsidian, Notion, VS Code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON&lt;/strong&gt; array — great for data processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain text&lt;/strong&gt; — with custom delimiters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML&lt;/strong&gt; unordered list — for web-based notes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key Features
&lt;/h3&gt;

&lt;p&gt;⚡ &lt;strong&gt;Keyboard Shortcut (Alt+Shift+C)&lt;/strong&gt; — Copy all tabs without opening the popup&lt;/p&gt;

&lt;p&gt;💾 &lt;strong&gt;Save &amp;amp; Restore Tab Groups&lt;/strong&gt; — Save your tabs as named groups locally. Restore them in a new window later. Great for context switching.&lt;/p&gt;

&lt;p&gt;📤 &lt;strong&gt;Export as Files&lt;/strong&gt; — .md, .txt, .json, or .html with auto timestamps&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;Smart Filtering&lt;/strong&gt; — Auto-excludes chrome://, edge://, about: pages. Optionally exclude pinned tabs.&lt;/p&gt;

&lt;p&gt;🔧 &lt;strong&gt;Custom Delimiter&lt;/strong&gt; — Set your own separator for text output (e.g., &lt;code&gt;|&lt;/code&gt; for CSV-style)&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;12 Languages&lt;/strong&gt; — English, 中文, 日本語, 한국어, Deutsch, Français, Español, Português, Русский, Bahasa Indonesia, and more&lt;/p&gt;

&lt;p&gt;🎨 &lt;strong&gt;Dark Mode&lt;/strong&gt; — Auto-follows system theme&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Preact + TypeScript + Tailwind CSS + Vite + crxjs
Bundle: ~63KB (gzipped: ~17KB)
Dependencies: 0 external runtime deps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose &lt;strong&gt;Preact over React&lt;/strong&gt; deliberately. For a Chrome extension popup, every kilobyte matters. Preact's 3KB footprint vs React's 40KB+ is a no-brainer. Plus, with &lt;code&gt;@preact/preset-vite&lt;/code&gt;, the DX is nearly identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Clipboard Challenge (Manifest V3)
&lt;/h3&gt;

&lt;p&gt;The hardest technical problem was clipboard access in Manifest V3's Service Worker context.&lt;/p&gt;

&lt;p&gt;In MV3, the background page is replaced by a Service Worker — which has no DOM. The &lt;code&gt;document.execCommand('copy')&lt;/code&gt; fallback doesn't work there. Here's my solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Double-fallback clipboard strategy&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyToClipboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Strategy 1: Modern Clipboard API (works in popup + offscreen)&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClipboardItem&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Strategy 2: execCommand fallback (popup context only)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textarea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textarea&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Strategy 3: Chrome Offscreen Document (Service Worker context)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Route clipboard write through the offscreen document&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  i18n Architecture
&lt;/h3&gt;

&lt;p&gt;I used Chrome's built-in &lt;code&gt;chrome.i18n&lt;/code&gt; API with 12 locale JSON files. The extension name and description in &lt;code&gt;manifest.json&lt;/code&gt; use &lt;code&gt;__MSG_key__&lt;/code&gt; placeholders, so the Chrome Web Store automatically displays the right language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_locales/ja/messages.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TabScribe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensionDesc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"すべてのタブをMarkdown/JSON/HTMLで一括コピー"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Privacy by Design
&lt;/h2&gt;

&lt;p&gt;TabScribe collects &lt;strong&gt;zero data&lt;/strong&gt;. Period.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No analytics&lt;/li&gt;
&lt;li&gt;No tracking pixels&lt;/li&gt;
&lt;li&gt;No external servers&lt;/li&gt;
&lt;li&gt;No network requests (except extension updates from Chrome)&lt;/li&gt;
&lt;li&gt;Everything stored in &lt;code&gt;chrome.storage.local&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only 6 permissions requested, each with a clear purpose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tabs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read tab titles &amp;amp; URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clipboardWrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy formatted text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Save preferences &amp;amp; tab groups locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commands&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Register keyboard shortcut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloads&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Save exported files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;offscreen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clipboard access in Service Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;🔗 &lt;a href="https://chromewebstore.google.com/detail/tabscribe-copy-all-tabs-a/jlochdcehejdkemnmlkphhkjnadegcgm" rel="noopener noreferrer"&gt;TabScribe on Chrome Web Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's free. No signup. No tracking. Just productivity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Preact + TypeScript + Tailwind CSS. Source code available on &lt;a href="https://github.com/auraflame/tabscribe" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>extension</category>
      <category>productivity</category>
      <category>markdown</category>
    </item>
  </channel>
</rss>
