<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Browserless Testing on Sven Ruppert</title><link>https://svenruppert.com/tags/browserless-testing/</link><description>Sven Ruppert — Java Veteran, Speaker, Trainer &amp; Bushcrafter. Articles, talks, workshops and videos on Core Java, Cybersecurity, Vaadin and Developer Relations.</description><generator>Hugo</generator><language>en</language><managingEditor>sven.ruppert@gmail.com (Sven Ruppert)</managingEditor><webMaster>sven.ruppert@gmail.com (Sven Ruppert)</webMaster><copyright>© 2026 Sven Ruppert</copyright><atom:link href="https://svenruppert.com/tags/browserless-testing/index.xml" rel="self" type="application/rss+xml"/><image><url>https://svenruppert.com/img/sven-ruppert.jpg</url><title>Sven Ruppert</title><link>https://svenruppert.com/tags/browserless-testing/</link></image><lastBuildDate>Fri, 21 Aug 2026 10:00:00 +0200</lastBuildDate><item><title>From ClickUp to Vaadin — Part 2: The Build and the Balance</title><link>https://svenruppert.com/posts/from-clickup-to-vaadin-part-2-the-build-and-the-balance/</link><pubDate>Fri, 21 Aug 2026 10:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/from-clickup-to-vaadin-part-2-the-build-and-the-balance/</guid><description>The second part: from the seven-view design of Part 1 to a running application in seventy-two commits, and an honest reckoning of the calculation that has tipped over. What the tool did well (drag-and-drop as ordinary Java, twelve lines from a Select to the hardened domain method, thirty-five stylesheet lines for a look of one's own) and what it did not (an import silently truncated at one hundred entries). Browserless view tests that reach into the real component tree, mutation testing as a check on tests written by the same assistant that wrote the code, and a balance sheet that names both columns of what a rented service actually costs — including the ability to leave.</description><content:encoded>&lt;![CDATA[<h2 id="where-we-stand">Where we stand</h2><p>The first part ended with a model and a reservation. Anyone who has read it knows both; for anyone joining here, let the starting position be given in a few sentences, for without it much of what follows remains mere assembly instructions.</p><p>The point of departure was a flat list of tasks in a foreign tool whose actual structure did not reside in the system but in a naming convention. From it came a four-level ordering of content, and from a single status field, overloaded over the years with sixteen values, came three mutually independent dimensions: acquisition, editorial work and production. The history of each of these dimensions is not overwritten but appended, ordered by an ascending sequence number rather than by a timestamp. On this foundation there arose a domain concept, a catalogue of processes, and out of these seven views, whose design finally lay before me as an interactive model.</p><p>The reservation ran that a model which has to hold its own only against itself always wins. This second part is the redemption: the design becomes an application through which the real holdings pass.</p><p>Let the technical ground be named in advance, so that no guessing is required later on. The application is written throughout in Java, the interface with Vaadin; storage is handled by EclipseStore over an object graph, delivery by an embedded Jetty, and permissions by a slender tool of my own. Neither an application server nor a comprehensive framework is employed; where the JDK suffices, it suffices. No introduction to Vaadin&rsquo;s building blocks is offered — they are presumed familiar, and what is interesting is in any case not which component displays something, but at which points the building blocks did not suffice.</p><p>What follows is therefore not a success story but a workshop report: the build in the order of the design briefs, the transfer of the model into the interface, the two views without a ready-made component, the bridge from the old system, verification without a browser, and at the end a reckoning that also names what went wrong.</p><h2 id="the-build-in-the-order-of-the-briefs">The build, in the order of the briefs</h2><p>Between the design and the running application lies that stretch about which articles of this kind usually keep silent — presumably because it is unspectacular. Yet it is precisely this stretch that decides whether the calculation claimed at the outset works out. The implementation followed directly on from the view briefs and comprised seventy-two commits in the end. The path there ran through three discernible phases: the building, the alignment, the hardening.</p><p>The building followed an order that emerged inevitably from the preceding stations. First the domain core with the status dimensions and the append-only history, then the aggregates together with the language rule as an invariant, after that the persistence, the permissions and the navigation scaffold. Only then came the views — individually, along the view briefs: the topic workspace, the language-version editor, the publication view, the editorial board, the history view, the publication places and finally the import. This order is no coincidence; it is the briefs worked through from top to bottom — with a single deviation: the publication view, the dramaturgical core, was pulled ahead of the editorial board. Anyone wondering what the detour via process catalogue and view briefs was good for will find the answer here: the implementation no longer had to invent anything.</p><p>One detail of this phase amuses me in retrospect. The domain core was first written with German identifiers —<code>Statusverlauf</code>,<code>Statuswechsel</code>,<code>Sprachfassung</code> — exactly as we had spoken about them in conversation. A refactoring of its own later tidied this up and translated the entire code into English, a good seventeen hundred changed lines across fifty-seven files. What appeared in the first part as<code>StatusHistory</code> had borne a different name before. The domain language of the conversation is simply not automatically the language of the code, and it is better to decide that early than late.</p><h2 id="the-model-in-the-interface">The model in the interface</h2><p>So far I have spoken about the interface without showing it. That is unsatisfactory, for the interesting question is precisely how such a model reappears in an interface — and whether anything is lost along the way. Let us begin with the screen that carries the core of the whole.</p><p>The two-column publication view illustrated in the first part arises from a handful of lines:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">FlexLayout</span><span class="w"/><span class="n">columns</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">FlexLayout</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">setWidthFull</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">getStyle</span><span class="p">().</span><span class="na">set</span><span class="p">(</span><span class="s">"gap"</span><span class="p">,</span><span class="w"/><span class="s">"var(--lumo-space-l)"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">setFlexWrap</span><span class="p">(</span><span class="n">FlexLayout</span><span class="p">.</span><span class="na">FlexWrap</span><span class="p">.</span><span class="na">WRAP</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">acquisitionColumn</span><span class="p">(),</span><span class="w"/><span class="n">productionColumn</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">body</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">columns</span><span class="p">);</span></span></span></code></pre></div></div><p>More revealing than the layout, however, is what happens inside such a column:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="n">Div</span><span class="w"/><span class="nf">productionColumn</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Select</span><span class="o">&lt;</span><span class="n">ProductionStatus</span><span class="o">&gt;</span><span class="w"/><span class="n">select</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Select</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">setItems</span><span class="p">(</span><span class="n">ProductionStatus</span><span class="p">.</span><span class="na">values</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">setValue</span><span class="p">(</span><span class="n">publication</span><span class="p">.</span><span class="na">productionStatus</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">addValueChangeListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="n">publication</span><span class="p">.</span><span class="na">productionStatus</span><span class="p">())</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">publication</span><span class="p">.</span><span class="na">changeProductionStatus</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">(),</span><span class="w"/><span class="n">actor</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">persist</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">render</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">});</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">...</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>These twelve lines are the actual argument. The selection field is populated directly with the values of the enumeration — no transfer objects, no strings, no second maintenance of the states between interface and model. The event handler calls the hardened domain method directly; between the click and the appending to the status history there lies no network call, no serialisation, no second language. The type parameter moreover ensures that the compiler complains if a dimension were to receive a value that does not belong to it. What began in the first part as disentangling arrives here.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-01.png" alt="Publication overview of the running application — one row per publication, acquisition and production side by side" loading="lazy" decoding="async"/><p><em>Figure 1: The publication overview of the running application — one row per publication, with acquisition and production state side by side.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-02.png" alt="Publication view of the running application — acquisition and production as two equal columns" loading="lazy" decoding="async"/><p><em>Figure 2: The publication view of the running application — acquisition on the left, production on the right, each with its own state and history.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-03.png" alt="History workspace of the running application — status changes of all three dimensions in one list" loading="lazy" decoding="async"/><p><em>Figure 3: The history overview of the running application — status changes of all three dimensions in one list, newest first, each row with a jump into its complete chain.</em></p><h2 id="the-editorial-board-across-four-steps">The editorial board across four steps</h2><p>How such a view improves over time can be read particularly well from the editorial board. It became what it is today in four steps, and each step had a nameable trigger. The improvement requests were numbered just as the processes had been before, so that every commit message carries its occasion with it — a small amount of orderliness that pays off when retracing the path.</p><p><strong>Step one — the requirement from the view brief.</strong> What was called for was a board showing the editorial progress of all parts at a glance, with one column per work state. Exactly that came about. Moving a card, however, was done through a selection field sitting on every single card:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">Select</span><span class="o">&lt;</span><span class="n">EditorialState</span><span class="o">&gt;</span><span class="w"/><span class="n">move</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Select</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setItems</span><span class="p">(</span><span class="n">EditorialState</span><span class="p">.</span><span class="na">values</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setValue</span><span class="p">(</span><span class="n">part</span><span class="p">.</span><span class="na">editorialState</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setWidthFull</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">addValueChangeListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="p">{</span><span class="w"/><span class="p">...</span><span class="w"/><span class="p">});</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">card</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"/><span class="n">info</span><span class="p">,</span><span class="w"/><span class="n">move</span><span class="p">);</span></span></span></code></pre></div></div><p>This satisfied the requirement, but it was not a board — it was a list with drop-down menus. The view brief had in fact provided for drag-and-drop, yet the first implementation took the shorter road — a behaviour I have observed in the assistant more than once: it fulfils the letter of the requirement, not its spirit, as long as one does not follow up.</p><p><strong>Step two — the request for genuine drag-and-drop.</strong> The follow-up was raised as request F4, and here it becomes apparent what is meant by consistently doing everything in Java:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">DropTarget</span><span class="o">&lt;</span><span class="n">Div</span><span class="o">&gt;</span><span class="w"/><span class="n">dropTarget</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">DropTarget</span><span class="p">.</span><span class="na">create</span><span class="p">(</span><span class="n">col</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dropTarget</span><span class="p">.</span><span class="na">addDropListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">onDrop</span><span class="p">(</span><span class="n">state</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">DragSource</span><span class="o">&lt;</span><span class="n">Div</span><span class="o">&gt;</span><span class="w"/><span class="n">dragSource</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">DragSource</span><span class="p">.</span><span class="na">create</span><span class="p">(</span><span class="n">card</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">setDragData</span><span class="p">(</span><span class="n">part</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">addDragStartListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">part</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">addDragEndListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="kc">null</span><span class="p">);</span></span></span></code></pre></div></div><p>No JavaScript, no event handling in the browser, no mediation between two worlds — drag-and-drop is an ordinary Java interface here. And both paths of interaction converge on the same domain method, which prevents two routes from drifting apart:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">onDrop</span><span class="p">(</span><span class="n">EditorialState</span><span class="w"/><span class="n">target</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">draggedPart</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="n">draggedPart</span><span class="p">.</span><span class="na">editorialState</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="n">target</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">draggedPart</span><span class="p">.</span><span class="na">changeState</span><span class="p">(</span><span class="n">target</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">persist</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="kc">null</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">refresh</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>It is worth noting that the selection field was deliberately retained at first; the source code expressly recorded it as an equally capable, accessible fallback for anyone unable or unwilling to drag with a mouse.</p><p><strong>Step three — the request for a second form of display.</strong> In daily use it turned out that the board becomes unwieldy with many parts and that a tabular view of the same data was missing. With this addition the selection field on the cards became dispensable, since the table offered a mouse-free route in any case. The third step therefore did not merely add something, but took something away: the selection field disappeared together with its import, and the card has since carried only title, part and markers. It belongs to the honesty of this account that such a removal comes more easily when an assistant carries the change through without appreciable effort; by hand I would probably have left the working selection field where it was.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch04-bild-01.png" alt="Editorial board of the running application — columns per editorial state, cards without the dispensable select" loading="lazy" decoding="async"/><p><em>Figure 1: The editorial board of the running application — one column per work state, the cards without the selection field that had become dispensable.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch04-bild-02.png" alt="The same data as a table — grouped by editorial state" loading="lazy" decoding="async"/><p><em>Figure 2: The same data as a table — grouped by work state.</em></p><p><strong>Step four — the objection of an analysis tool.</strong> The last step did not come out of conversation. The board kept the filtered parts cached in a field in order to share them between the column and table views — convenient, but a piece of state that can go stale and which the static analysis rightly complained about. In its place came a method that recomputes on demand:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">Part</span><span class="o">&gt;</span><span class="w"/><span class="nf">filteredParts</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">PublicationsFilter</span><span class="w"/><span class="n">filter</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">PublicationsFilter</span><span class="p">.</span><span class="na">current</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">issues</span><span class="p">().</span><span class="na">stream</span><span class="p">()</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">filter</span><span class="p">(</span><span class="n">filter</span><span class="p">::</span><span class="n">matchesTitle</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">flatMap</span><span class="p">(</span><span class="n">i</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">i</span><span class="p">.</span><span class="na">parts</span><span class="p">().</span><span class="na">stream</span><span class="p">())</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">toList</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>Four steps: a list with drop-down menus became a board, the board became a leaner board with two forms of display, and in the end a cache disappeared that could have attracted defects. Further requests followed later — compound search filters and an optional AND or OR combination of tags — yet the pattern remained the same. None of these steps is remarkable in itself. What is remarkable is that they took place at this density, for the fact that an improvement barely weighs anything at all decides whether one makes it.</p><p>Finally, a small matter that pleases me, because it carries the method from the first part right into the source code. Every view names in its description the processes it serves:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="cm">/**</span></span></span><span class="line"><span class="cl"><span class="cm"> * Served processes: P0008–P0013.</span></span></span><span class="line"><span class="cl"><span class="cm"> */</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@Route</span><span class="p">(</span><span class="n">value</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">PublicationView</span><span class="p">.</span><span class="na">NAV</span><span class="p">,</span><span class="w"/><span class="n">layout</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">MainLayout</span><span class="p">.</span><span class="na">class</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@VisibleFor</span><span class="p">(</span><span class="n">AuthorizationRole</span><span class="p">.</span><span class="na">USER</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">class</span><span class="nc">PublicationView</span><span class="w"/><span class="kd">extends</span><span class="w"/><span class="n">Composite</span><span class="o">&lt;</span><span class="n">VerticalLayout</span><span class="o">&gt;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">implements</span><span class="w"/><span class="n">HasUrlParameter</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span><span class="p">,</span><span class="w"/><span class="n">I18nSupport</span><span class="w"/><span class="p">{</span></span></span></code></pre></div></div><p>Anyone wanting to know in six months&rsquo; time why this screen exists will find the answer where they would look anyway.</p><h2 id="the-seam-a-look-of-ones-own-in-thirty-five-lines">The seam: a look of one&rsquo;s own in thirty-five lines</h2><p>Now to that seam which I named openly at the end of the first part: the draft was a free front-end mock-up, and Vaadin brings its own visual language. How does one close that? The answer turned out to be smaller than I had expected. A single commit brought the IBM Plex fonts and the colour palette into the application, and it comprised thirty-five changed lines in a single stylesheet. This is possible because Vaadin&rsquo;s Lumo styling can be redefined through CSS custom properties — one exchanges typeface, colours and corner radii without touching a single component. The seam was real, then, but it was narrow. Whoever wants a look of their own can have it; they need only know that it comes about in the stylesheet and not in the Java code.</p><h2 id="the-bridge-the-import-as-an-etl">The bridge: the import as an ETL</h2><p>Between the old system and the new one stands a bridge, and it is of a different kind from everything built so far. It does not belong to the application but to the transition. It is needed for as long as data resides in ClickUp, and it disappears the moment that ceases to be the case. A throwaway piece, then — yet one on which the entire migration depends, because not a single correspondence between source and target arises of its own accord. That is precisely why the import is built as an ETL rather than as a script that runs once and hopes.</p><p>The value of the tripartite structure lies in the strict separation of the three acts. Extraction fetches the task list over the network, writes it unaltered to disk, and is thereby finished. Transformation and loading then work exclusively against that local copy and may be repeated at will. This is no formality but the heart of the matter: the interesting work resides in the transformation, and transformations are rarely got right at the first attempt. Anyone who reaches across the network for every attempt turns a swift loop into a laborious affair and, into the bargain, surrenders to the rate limits of a foreign interface.</p><p>The raw copy consists of two files, the JSON document and a note recording the moment of extraction. What is remarkable is what gets preserved: every field of the source, not merely those the transformation currently consumes. That restraint is deliberate. What one does not need today cannot be retrieved later, once the source has been switched off. The store itself remains deliberately undemanding — it is handed its directory, and the timestamp along with it, rather than consulting a clock, in which it follows the domain model&rsquo;s abstention from embedded time. On the network side, the JDK&rsquo;s HTTP client suffices; no framework is required at any point.</p><p>The actual act of transformation is the one towards which the first part of this account was working: the distribution of the conflated status field. The analysis has become a mapping table, and the mapping table an executable case distinction. And here a limitation must be named openly, one the migration cannot resolve. Only the editorial dimension is populated. Production and acquisition were never recorded independently in ClickUp, and no transformation invents what was never gathered. The parts taken over therefore begin in the neutral initial states of the two other dimensions. The disentanglement acts forwards, not retrospectively — it provides the model in which the distinction can be maintained from now on.</p><p>Two decisions at this juncture strike me as the most instructive in the whole module. First, an unrecognised status value does not drop out but drops into the backlog. A record that cannot be mapped is not lost; it lands visibly where somebody will have to take it up again. Second, the report of a run carries not merely tallies but a frequency-counted mapping of the form &ldquo;source value became target state&rdquo;. This turns the import into an instrument of inspection: one reads off which of the sixteen ClickUp values fell how often onto which editorial state, and holds the mapping table devised at the desk against the actual data.</p><p>That leaves repeatability, and it rests on a single field. Every case taken over records its origin, the identifier of the ClickUp task. Should loading encounter an origin already recorded, it passes the record over. A second run therefore creates nothing but merely reports what it skipped. Only this property makes the bridge serviceable in daily use: one imports, examines the distribution, improves the mapping, imports again — and need tidy nothing in between.</p><p>The self-description written into the module puts it exactly: a throwaway module in production, a workhorse in development. The reckoning becomes wholly honest only later, however. Extraction walks the pages of the foreign interface in a loop, and the fact that it did not do so at first was the most consequential error of the entire undertaking. That it was noticed at all is owed to the raw copy: because the extract lies complete on disk, one can count what arrived and compare it with what ought to have been there.</p><h2 id="verification-without-a-browser">Verification without a browser</h2><p>Every station of this account so far runs towards the same uncomfortable question, and I have only brushed against it until now. If an assistant writes the code and then writes the tests for it, what do those tests actually prove? They check what the assistant understood. A misunderstanding propagates from the code into the test, where it is not exposed but confirmed. Whoever works this way and trusts green bars has built themselves a machine that agrees with them.</p><p>First to the craft side, for without it there would be nothing to verify at all. A Vaadin interface can be tested without a browser: the view is created in the same runtime in which the check runs, and one reaches into the real component tree rather than into rendered HTML. No browser, no remote-control protocol, no waiting seconds — and above all no brittleness with respect to labels and positions. Such a check looks like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Test</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@DisplayName</span><span class="p">(</span><span class="s">"renders both lifecycle columns with their initial states"</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kt">void</span><span class="w"/><span class="nf">rendersTwoColumns</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">UI</span><span class="p">.</span><span class="na">getCurrent</span><span class="p">().</span><span class="na">navigate</span><span class="p">(</span><span class="n">PublicationView</span><span class="p">.</span><span class="na">class</span><span class="p">,</span><span class="w"/><span class="n">vId</span><span class="p">.</span><span class="na">toString</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertEquals</span><span class="p">(</span><span class="s">"Publication"</span><span class="p">,</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">H1</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">first</span><span class="p">().</span><span class="na">getText</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span><span class="w"/><span class="n">spanTexts</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">Span</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">all</span><span class="p">().</span><span class="na">stream</span><span class="p">()</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">map</span><span class="p">(</span><span class="n">Span</span><span class="p">::</span><span class="n">getText</span><span class="p">).</span><span class="na">collect</span><span class="p">(</span><span class="n">Collectors</span><span class="p">.</span><span class="na">toList</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"ACQUISITION"</span><span class="p">),</span><span class="w"/><span class="s">"left column is Acquisition"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"PRODUCTION"</span><span class="p">),</span><span class="w"/><span class="s">"right column is Production"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"REQUESTED"</span><span class="p">),</span><span class="w"/><span class="s">"acquisition starts at REQUESTED"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"PLANNED"</span><span class="p">),</span><span class="w"/><span class="s">"production starts at PLANNED"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertEquals</span><span class="p">(</span><span class="n">2</span><span class="p">,</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">Select</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">all</span><span class="p">().</span><span class="na">size</span><span class="p">(),</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="s">"one advance-Select per dimension"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>What is remarkable here is less the technique than what is actually being checked. The claim that carries this entire article — two independent lifecycles on a single object — has at this point become a guarantee that is recomputed with every build. Were someone to remove one of the two columns, the test would fail. The disentangling from the analysis is thus not merely modelled and drawn, but pinned down.</p><p>Equally revealing is the preparation of such checks. They build their own body of data from scratch — a publication place, a topic, a part, a language version, a planned publication — and store it in a second, purely in-memory persistence implementation that stands alongside the file-based one. That an object graph can be replaced by a stand-in so easily is a quiet advantage of doing without a database: there is no schema to create beforehand, and no server to start.</p><p>By now the undertaking comprises sixty-one test classes with close to three hundred checks; twenty-one of those classes are browserless view tests. And here the opening question returns: these figures impress, but on their own they prove nothing. The truncated import from the previous station was surrounded by tests that all ran green.</p><p>This is why one needs means of verification that come from outside — that do not know what the assistant meant. The first is static analysis, which found seventeen complaints in the code of the publication management: missing serialisation identifiers, mutable collections handed straight out. None of it was an error of thought, all of it was craftsmanlike negligence, and not one of the tests would ever have noticed.</p><p>The second and more effective means is mutation testing, and it answers precisely the question at issue here. It deliberately alters the code in many places in small ways — inverts a condition, swaps a return value, removes a call — and then runs the tests. If no test notices the alteration, one has a test that passes but secures nothing. Where the usual coverage measurement only counts which lines were executed, mutation testing asks whether their behaviour is specified at all. For tests that come from the same instance as the code, that is the only honest counter-check.</p><p>How this is arranged in the project is, to my mind, the actual lesson of this chapter. A single global threshold would be useless, because it either overtaxes the interface or undertaxes the core. Instead, a dedicated file keeps per-package floors, and its comment states the expectations openly: the load-bearing security and domain packages lie between seventy-five and ninety-five per cent, the view packages deliberately far below. The floors are deliberately set two or three points below the measured state, so that ordinary refactorings do not trip them while a genuine regression does; they may be raised only after a sustained better state, and lowered only with a written reason.</p><p>Such a reason does in fact stand in the file, and it is more instructive than any success report: the overall floor was lowered when new interface components were added that generate many mutants in pure assembly code — code, that is, which plugs components together and in which there is little to assert. That is the honest way to handle a metric: not to flatter it, but to write down why it moved.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch07-bild-01.svg" alt="Levels of verification and what each of them catches" loading="lazy" decoding="async"/><p><em>Figure 1: Levels of verification and what each of them catches — inner consistency can be automated, correctness cannot.</em></p><p>What remains is the part no tool covers. Neither the static analysis nor the mutation testing would have noticed that the import breaks off at one hundred tasks, for both check the agreement of code and test, not the agreement of result and reality. That falls to the human who knows their own body of data. The division of labour that follows from this is unspectacular but sound: the assistant produces, the tools verify inner consistency, and the human verifies whether the whole thing is the right thing at all.</p><h2 id="bug-fixing-and-fitness-for-daily-use">Bug fixing and fitness for daily use</h2><p>The bug fixing produced the most instructive find of the whole undertaking. The import ran flawlessly at first — too flawlessly. It fetched one hundred tasks and reported success. That the ClickUp interface delivers its results page by page and silently truncates after a hundred entries only came to light when the figures did not match the actual body of data. The fix walked the pages in a loop and concatenated them into a single raw document, with a hard upper bound so that a misbehaving interface can never lead into an endless loop. This is the sort of error no tool finds for you: there was no exception, no failing test, no warning — only a result that looked plausible and was wrong. Here the limit of the acceleration shows itself quite precisely. The tool wrote the import effortlessly; that it imported too little was noticed by the human who knows their own body of data.</p><p>Less dramatic, but telling, was the second group of findings. A static analysis found seventeen complaints in the code of the publication management, all of the boring kind: missing serialisation identifiers on the views, mutable collections passed straight out that are better copied defensively. They were resolved with fifty-six added lines. It is precisely that layer of craftsmanlike care which an assistant reliably overlooks as long as no one points it out — and which an analysis tool just as reliably finds. That the two together are better than either alone is the actual lesson.</p><p>This phase also confirmed what the view briefs had predicted. The editorial board and the history view remained custom builds; at three hundred and fifty-four lines the board is the second-largest of the seven publication views, complete with hand-wired drag-and-drop between the columns. This was no rude awakening, because it had been announced — and therein lies the whole value of a design plan that names its gaps.</p><p>What came afterwards is the actual work on an application one wants to use daily, and it fills releases thirty-two to forty: filters across several criteria, tag combinations as either AND or OR, adjustable column widths, return paths that lead back to where one came from, entries editable in place. This phase cannot be shortened, for it consists entirely of small observations one makes only once one really uses one&rsquo;s own tool. The assistant implements them quickly; it is the user who must notice them.</p><p>On balance the calculation held, but not in the way naive expectation would suggest. The path from the empty directory to a usable application was, for an individual working alongside their ongoing commitments, remarkably short. The acceleration, however, lies almost entirely in the making — in the writing of classes, views, tests and translations. It does not lie in the discovering. The truncated import, the wrong return path, the awkward handling: all of that had to be noticed by someone who knows how the thing ought to feel.</p><h2 id="an-honest-balance-sheet">An honest balance sheet</h2><p>An article that tells of a calculation tipping over owes the reader both columns of that calculation. My first draft of this chapter had a flaw I noticed only on re-reading: it listed the costs of the self-built solution meticulously and set against them a rented service whose costs remained unquantified. That is not a balance sheet, but a one-sided statement. So: both columns.</p><p>The item that cannot be argued away is maintenance. Every library ages, security vulnerabilities are reported, a new Java release appears — and no one but me will attend to it. This work does not end. It is, however, smaller than the word suggests, and precision helps more here than drama: this is an application with a single user, without an availability guarantee, without tenant separation and with few dependencies. The effort accordingly remains manageable.</p><p>More decisive than the size of this item is its nature, and here lies the actual gain. My maintenance burden obeys me. I decide when to raise a dependency; if I touch nothing for a year, the application continues to run as before. The changes of a rented service, by contrast, befall me. I can neither schedule nor decline them: prices rise, features disappear or are rebuilt, an interface is rearranged, a provider is acquired. Both are costs, yet only one kind is under my control. This distinction between costs one commands and costs that befall one is the substantive core of what I mean by sovereignty.</p><p>Which brings us to the second column, missing from the draft. ClickUp cost more than a monthly fee. It cost the daily detours from the first chapter — the makeshifts via tags, the overloaded status field, the impossibility of asking a single question cleanly. It cost the operation of an abundance of features of which I used a narrow slice. And it cost something that becomes visible only in earnest: the ability to leave. Whoever owns neither their data nor the source code cannot switch when faced with an unwelcome change, but only acquiesce. These items appear on no invoice, and that is precisely why one overlooks them for years.</p><p>The third item is dependency, and here I must examine my own principles. I like to keep close to the JDK; neither Spring nor Jakarta EE appears in this application. This is no idiosyncrasy but follows from the scope: an application server and a dependency-injection framework solve problems I do not have here — tenants, distributed transactions, interchangeable implementations within large teams. Vaadin Flow runs on an embedded Jetty, the wiring is done by ordinary Java code, and what remains can be read and surveyed in full. Vaadin, however, is anything but a small dependency — an extensive framework with a runtime world and a build tooling of its own. Whoever adopts it exchanges the fault line between Java and JavaScript for a tie to one vendor. I consider the exchange advantageous, because it removes precisely the friction that otherwise cost me most, and because the result sits with me as open source and remains compilable even if I update nothing further. An exchange it nonetheless remains. Added to this are the object store and a tool for reading JSON; the application is far from &ldquo;only the JDK&rdquo;. The aim was always restraint, not asceticism.</p><p>The fourth item concerns the reverse side of data sovereignty. My data now sit with me — and so does the responsibility for them. About backups, failing drives and a move to new hardware I never had to think with ClickUp. Now I do. At least this item is solvable and set up once, and it has a reverse side that weighs more heavily for me: a backup I make myself I can also restore myself — and I know what format it is in.</p><p>Something must also be said about the assistant that the account so far only touches upon. It has accelerated the making extraordinarily, but it possesses no judgement as to whether what has been made is correct. The truncated import from the previous station is the best example: clean code, green tests, wrong result. The tests carry this weakness within them too, for they check what the assistant understood — a misunderstanding propagates from the code into the test and is confirmed there. Whoever works this way needs means of verification from outside: static analysis, an assessment of test quality through deliberate corruption of the code, and above all one&rsquo;s own eye. This insight does not argue against the path, but it determines how one walks it.</p><p>Two points I leave without consolation, because none is due to them. The first is dependency on a single person: were I to drop out, there would be no one to maintain this application further. With a rented service that would be otherwise, and no argument helps against it. The second is the timing of this article: the application is still young. Whether it proves itself in daily use over months, whether decisions that look sensible today will come back to haunt me in half a year — that I simply do not know. Anyone claiming certainty here does not have it.</p><p>A good deal has moreover remained open. The PDF generation, one of the original motives, is not implemented and will require a further deliberate exception to the dependency principle. The commercial world was left out — rightly so, but it is missing. The import serves its purpose, yet the final switchover with a cut-off date and a reconciliation is still to come.</p><p>There remains the question of what the calculation now looks like. Its two sides run in opposite directions: the building effort was one-off and lies behind me, the rent would be recurring and would, by experience, grow. The benefit persists for as long as I write, and it consists not merely in fees saved but in something that was previously impossible — an application that mirrors my workflow instead of pressing it into a foreign model, data in a format I know, and source code that lives on without the assistant that helped write it. The calculation has therefore tipped over, but it has not become trivial. It does not read &ldquo;building your own is now free&rdquo;, but rather: the building effort has fallen so far that ongoing maintenance becomes the decisive item. And that maintenance is, unlike the changes of a foreign service, a burden one holds in one&rsquo;s own hands.</p><h2 id="outlook">Outlook</h2><p>Three steps are foreseeable, and they stand in a natural order.</p><p>The next is the final switchover. So far the new application runs alongside ClickUp, fed by an import that can be repeated at will. At some point there will need to be a cut-off date, a last reconciliation and the decision to stop feeding the old service. That moment is unspectacular and at the same time the real test: only when I use the tool without a fallback will it become apparent whether it is fit for purpose.</p><p>The second step is the PDF generation — that motive from the first chapter which has so far remained unfulfilled. Typesetting collected editions from the body of work was never conceivable with a task manager, and now it is merely a question of doing the work. It will require a further deliberate exception to my dependency principle, for the JDK brings nothing along for typesetting documents. That I make this exception and do not conceal it is preferable, to my mind, to pretending that everything can be built from on-board resources.</p><p>The third step is the commercial world I deliberately set aside in the first part: clients, remuneration models, invoices, a forecast of expected income. The lean model was designed from the outset as a genuine subset, not as a stopgap, and the transition therefore proceeds by addition rather than by rewriting. Whether this assurance holds remains to be seen — it is the kind of promise one makes to oneself and which reality occasionally declines to honour.</p><p>And beyond that? The appeal of one&rsquo;s own application lies in the fact that every small observation in daily work can lead directly to a change, rather than to an entry on a wish list administered by someone else. That is precisely the difference I was looking for at the beginning without being able to name it: I was not seeking a better ClickUp, but a tool that obeys me instead of shaping me. Whether the calculation works out in the long run I shall know in a year. That it could be drawn up at all is new — and that is the actual news of this article.</p><h2 id="related-on-this-site">Related on this site</h2><ul><li><strong><a href="/posts/from-clickup-to-vaadin-part-1-the-thesis-and-the-model/">From ClickUp to Vaadin — Part 1: The Thesis and the Model</a></strong> — the first part: the direct look into the running ClickUp workspace, the disentanglement of one status field into three orthogonal dimensions, and the hardened append-only history.</li><li><strong><a href="/posts/vaadin-flow-how-to-start/">Vaadin Flow — How to Start</a></strong> — the entry-level walkthrough for the framework used throughout this account.</li><li><strong><a href="/posts/vaadin-25-2-security-innovations/">The Security Innovations in Vaadin 25.2</a></strong> — the platform-level defaults this application inherits from Vaadin&rsquo;s own runtime.</li></ul>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg"/><enclosure url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg" type="image/jpeg" length="0"/></item></channel></rss>